commit 7b3816441c4777746e25db40e771daf35d7e1499 Author: Aria2 Rust Pro Contributors Date: Sat Jul 18 14:04:26 2026 +0800 chore: initial sanitized public snapshot diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9bb682a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[term] +color = "auto" + +[unstable] +build-std = ["std", "panic_abort"] +build-std-features = ["optimize_for_size"] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d5546e --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94974e9 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..97c8827 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..204363c --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35ae2a8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c251298 --- /dev/null +++ b/CHANGELOG.md @@ -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` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d9ad9e1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing to aria2-rust-pro + +Thanks for contributing to `aria2-rust-pro`. + +This repository is maintained as a practical, compatibility-driven rewrite of +Aria2-Pro-Core on top of a Rust 2024 workspace. The most helpful contributions +are the ones that improve behavior, compatibility evidence, packaging, and +maintainability without drifting the external aria2-facing contract. + +## Development Setup + +Recommended local prerequisites: + +- Rust toolchain from `rust-toolchain.toml` +- PowerShell 7 on Windows +- Docker Desktop or a compatible Docker daemon for image validation +- `rtk` for compact command output and the repository's preferred terminal flow + +From the repository root: + +```powershell +rtk cargo metadata --manifest-path .\Cargo.toml --no-deps --format-version 1 +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +``` + +## Preferred Workflow + +1. branch from `main` +2. keep the edit scope tight and purpose-driven +3. follow existing crate boundaries instead of inventing new cross-cutting + abstractions too early +4. add or update tests when behavior changes +5. run the relevant gates before asking for review + +If your change affects release packaging or container behavior, also run the +owned smoke path: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local +``` + +## Required Quality Gates + +The expected final gates are documented in +[docs/testing/quality-gates.md](docs/testing/quality-gates.md). The usual +closeout set is: + +```powershell +rtk cargo fmt --all --check +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +rtk cargo clippy --workspace --all-targets --all-features --locked --no-deps -- -D warnings -D clippy::pedantic -D clippy::nursery -D clippy::cargo +rtk cargo +nightly udeps --workspace --all-targets --all-features --locked +rtk cargo deny --locked check +``` + +For a one-shot local sweep: + +```powershell +pwsh ./scripts/testing/strict-sweep.ps1 +``` + +## Repository Conventions + +- Prefer Cargo-native `xtask` entrypoints over ad hoc scripts when both exist. +- Keep public-facing behavior compatible unless the change is an intentional, + documented divergence. +- Preserve release evidence: manifests, checksums, version-smoke validation, + and migration notes matter here. +- Prefer `rtk` commands for repo work. +- Keep documentation in sync when changing deployment, Docker, migration, or + packaging behavior. + +### Commit Messages + +Use Conventional Commits for every authored commit: + +```text +type(scope)!: concise summary +``` + +Allowed types are `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `build`, +`ci`, `chore`, and `revert`. The scope and `!` are optional. Use `!` or a +`BREAKING CHANGE:` footer for intentional compatibility breaks. CI validates +non-merge commits after the `v1.0.0` baseline. + +### Versioning and Changelog + +The workspace package version in the root `Cargo.toml` is the release version. +Internal crate constraints are centralized in `[workspace.dependencies]`; do +not add crate-local copies. Release tags remain `vMAJOR.MINOR.PATCH` and must +match the workspace version. + +- `feat` is a minor-release candidate. +- `fix` and `perf` are patch-release candidates. +- `!` or `BREAKING CHANGE:` requires a major-release decision. +- `docs`, `refactor`, `test`, `build`, `ci`, and `chore` do not independently + require a version bump. + +`git-cliff` generates user-facing entries from Conventional Commits. Run the +following preview before a release-facing change: + +```powershell +rtk git-cliff --config .\cliff.toml --unreleased +``` + +### Dependency Updates + +Renovate creates dependency pull requests against `main`. Patch updates may +automerge only after CI succeeds; minor and major updates always remain review +pull requests. Lockfile maintenance is also review-only. + +## Pull Requests and Reviews + +When opening a review: + +- summarize the behavior change in plain language +- list the exact verification commands you ran +- call out compatibility-sensitive areas explicitly +- mention any remaining limits or follow-up work instead of hiding them + +## Release-Facing Changes + +If your change affects users directly, also update at least one of: + +- [CHANGELOG.md](CHANGELOG.md) +- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md) +- [docs/migration/README.md](docs/migration/README.md) + +That keeps the repository ready for an actual external release instead of only +an internal development snapshot. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8a57920 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2365 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "aria2-rust-pro-cli" +version = "1.0.0" +dependencies = [ + "aria2-rust-pro-compat", + "aria2-rust-pro-core", + "aria2-rust-pro-protocol", + "aria2-rust-pro-rpc", + "aria2-rust-pro-storage", + "ssh2", +] + +[[package]] +name = "aria2-rust-pro-compat" +version = "1.0.0" + +[[package]] +name = "aria2-rust-pro-core" +version = "1.0.0" +dependencies = [ + "aria2-rust-pro-storage", +] + +[[package]] +name = "aria2-rust-pro-protocol" +version = "1.0.0" +dependencies = [ + "adler2", + "aria2-rust-pro-storage", + "crc32fast", + "md-5", + "quick-xml", + "reqwest", + "sha1", + "sha2", +] + +[[package]] +name = "aria2-rust-pro-rpc" +version = "1.0.0" +dependencies = [ + "aria2-rust-pro-compat", + "aria2-rust-pro-core", + "aria2-rust-pro-protocol", + "aria2-rust-pro-storage", + "base64", + "serde_json", + "sha1", +] + +[[package]] +name = "aria2-rust-pro-storage" +version = "1.0.0" + +[[package]] +name = "aria2-rust-pro-tests" +version = "1.0.0" +dependencies = [ + "aria2-rust-pro-cli", + "aria2-rust-pro-compat", + "aria2-rust-pro-core", + "aria2-rust-pro-protocol", + "aria2-rust-pro-rpc", + "aria2-rust-pro-storage", + "criterion", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abf5d501fd757c2d2ee78d0cc40f606e92e3a63544420316565556ed28485e2" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xtask" +version = "1.0.0" +dependencies = [ + "anyhow", + "cargo_metadata", + "clap", + "flate2", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "time", + "walkdir", + "zip", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bb03783 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,91 @@ +[workspace] +members = [ + "crates/aria2-rust-pro-cli", + "crates/aria2-rust-pro-compat", + "crates/aria2-rust-pro-core", + "crates/aria2-rust-pro-protocol", + "crates/aria2-rust-pro-rpc", + "crates/aria2-rust-pro-storage", + "crates/aria2-rust-pro-tests", + "xtask", +] +resolver = "3" + +[workspace.package] +categories = ["command-line-utilities", "network-programming"] +description = "Rust 2024 rewrite of Aria2-Pro-Core with aria2 compatibility" +edition = "2024" +keywords = ["aria2", "download", "rpc", "bittorrent", "rust"] +license = "GPL-2.0-or-later" +readme = "README.md" +rust-version = "1.88" +version = "1.0.0" + +[workspace.dependencies] +aria2-rust-pro-cli = { version = "1.0.0", path = "crates/aria2-rust-pro-cli" } +aria2-rust-pro-compat = { version = "1.0.0", path = "crates/aria2-rust-pro-compat" } +aria2-rust-pro-core = { version = "1.0.0", path = "crates/aria2-rust-pro-core" } +aria2-rust-pro-protocol = { version = "1.0.0", path = "crates/aria2-rust-pro-protocol" } +aria2-rust-pro-rpc = { version = "1.0.0", path = "crates/aria2-rust-pro-rpc" } +aria2-rust-pro-storage = { version = "1.0.0", path = "crates/aria2-rust-pro-storage" } + +[workspace.lints.rust] +elided_lifetimes_in_paths = "deny" +future_incompatible = { level = "deny", priority = -1 } +missing_copy_implementations = "deny" +missing_debug_implementations = "deny" +missing_docs = "deny" +rust_2018_idioms = { level = "deny", priority = -1 } +rust_2021_compatibility = { level = "deny", priority = -1 } +rust_2024_compatibility = { level = "deny", priority = -1 } +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unreachable_pub = "deny" +unsafe_op_in_unsafe_fn = "deny" +unsafe_code = "forbid" +unused = { level = "deny", priority = -1 } +unused_crate_dependencies = "deny" +unused_import_braces = "deny" +unused_lifetimes = "deny" +unused_qualifications = "deny" +warnings = "deny" + +[workspace.lints.rustdoc] +bare_urls = "deny" +broken_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +as_conversions = "deny" +arithmetic_side_effects = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_precision_loss = "deny" +cast_sign_loss = "deny" +dbg_macro = "deny" +float_arithmetic = "deny" +indexing_slicing = "deny" +integer_division = "deny" +missing_docs_in_private_items = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +missing_safety_doc = "deny" +multiple_unsafe_ops_per_block = "deny" +todo = "deny" +undocumented_unsafe_blocks = "deny" +unimplemented = "deny" + +[profile.release] +codegen-units = 1 +debug = 0 +debug-assertions = false +lto = "fat" +opt-level = "s" +panic = "abort" +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbb5042 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# aria2-rust-pro + +`aria2-rust-pro` is a Rust 2024 rewrite of the current Aria2-Pro-Core +behavior, with upstream aria2 1.37.0 compatibility as the primary external +contract. + +This repository is the maintained Rust line for the project. It aims to keep +the familiar aria2 configuration and RPC surface, while moving the +implementation onto a modern Cargo-based toolchain, stricter quality gates, and +source-first packaging automation. + +## What This Repository Provides + +- a Rust-native `aria2-rust-pro` CLI with aria2-style config and RPC behavior +- a Docker image build that preserves the familiar Pro Docker environment model +- a Cargo-native `xtask` workflow for release packaging, Docker smoke tests, + and local export +- strict workspace-wide linting, testing, and dependency policy enforcement +- local release staging for Windows artifacts and portable Docker image tar + exports + +## Current Snapshot + +The repository already includes: + +- a local Windows release packaging flow +- a local Docker image export flow +- JSON-RPC and XML-RPC coverage for representative aria2 client paths +- BitTorrent, Metalink, HTTP, HTTPS, XML-RPC, JSON-RPC, and SFTP feature + surfaces in the current binary banner + +The current public repository state should be read as: + +- source of truth for ongoing Rust development +- suitable for local builds, local packaging, and local Docker deployment +- backed by a self-hosted Gitea `v1.0.0` release with Windows archive, Docker + tar, manifests, and checksum assets +- not yet presented as a public container registry or broad multi-platform + binary distribution service + +## Quick Start + +### Build the binary + +```powershell +rtk cargo build --release -p aria2-rust-pro-cli --bin aria2-rust-pro +``` + +### Check the version banner + +```powershell +.\target\release\aria2-rust-pro.exe --version +``` + +### Run the main validation lanes + +```powershell +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +``` + +### Package a local release artifact + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build +``` + +### Build and export a local Docker image tar + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker export-local --build +``` + +## Repository Guide + +- [docs/deployment/README.md](docs/deployment/README.md): native and Docker + deployment guide +- [docs/migration/README.md](docs/migration/README.md): migration notes from + existing Aria2-Pro-Core and Aria2-Pro-Docker installs +- [docs/release/README.md](docs/release/README.md): local release packaging, + Docker export, and artifact manifest flow +- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md): + release announcement template +- [docs/compatibility/aria2-compat-ledger.md](docs/compatibility/aria2-compat-ledger.md): + compatibility tracking ledger +- [docs/testing/quality-gates.md](docs/testing/quality-gates.md): required test + and lint gates +- [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow and repository + expectations +- [CHANGELOG.md](CHANGELOG.md): release-facing change history + +## Workspace Layout + +- `crates/aria2-rust-pro-cli`: CLI surface, config projection, runtime launch, + and rendering +- `crates/aria2-rust-pro-compat`: aria2-facing option and compatibility layer +- `crates/aria2-rust-pro-core`: scheduler, engine, runtime coordination +- `crates/aria2-rust-pro-protocol`: HTTP, BitTorrent, Metalink, and transport + domain logic +- `crates/aria2-rust-pro-rpc`: JSON-RPC, XML-RPC, routing, and handlers +- `crates/aria2-rust-pro-storage`: session and persistence layers +- `crates/aria2-rust-pro-tests`: integration, compatibility, and pressure tests +- `xtask/`: Cargo-native project automation +- `docker/`: image build, entrypoint, compose example, and bundled defaults + +## Packaging and Local Artifacts + +Local staged artifacts live under: + +- `dist/release/v/` for packaged release archives +- `dist/docker/v/` for portable Docker image tar exports + +The package and export commands emit checksum files and JSON manifests beside +the artifacts so a staged build can be moved to another machine and verified +without guessing which binary or image tag it came from. + +## Compatibility Direction + +The compatibility target is practical aria2 interoperability rather than a +novel CLI surface. That means the project prioritizes: + +- aria2-style config semantics +- existing RPC client compatibility +- migration paths from Aria2-Pro-Core and Aria2-Pro-Docker +- release packaging that keeps runtime evidence with the artifact + +## License + +`aria2-rust-pro` is licensed under [GPL-2.0-or-later](LICENSE). OpenSSL +linking-exception handling remains a release and documentation concern carried +forward from the aria2 ecosystem. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..b98772f --- /dev/null +++ b/cliff.toml @@ -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 }, +] diff --git a/crates/aria2-rust-pro-cli/Cargo.toml b/crates/aria2-rust-pro-cli/Cargo.toml new file mode 100644 index 0000000..90e3ed4 --- /dev/null +++ b/crates/aria2-rust-pro-cli/Cargo.toml @@ -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 diff --git a/crates/aria2-rust-pro-cli/src/args.rs b/crates/aria2-rust-pro-cli/src/args.rs new file mode 100644 index 0000000..be3f1b0 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/args.rs @@ -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) -> Result { + 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) -> Option { + (!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 { + 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 { + 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, +) -> Option { + (!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, CliError> { + let mut entries = Vec::new(); + let mut current_uris: Option> = None; + let mut current_directives = Vec::new(); + + let finalize_current = + |entries: &mut Vec, + current_uris: &mut Option>, + current_directives: &mut Vec| { + 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::>(); + 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, 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 { + 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, +) -> Option { + (!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 { + 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 { + 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 { + 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(args: &mut std::iter::Peekable, option: &str) -> Result +where + I: Iterator, +{ + 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) -> Result { + let mut args = args + .into_iter() + .map(|arg| arg.into_string().map_err(|_| CliError::InvalidUtf8Argument)) + .collect::, _>>()? + .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(), + } +} diff --git a/crates/aria2-rust-pro-cli/src/http_runtime.rs b/crates/aria2-rust-pro-cli/src/http_runtime.rs new file mode 100644 index 0000000..4220139 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/http_runtime.rs @@ -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; diff --git a/crates/aria2-rust-pro-cli/src/http_runtime/build.rs b/crates/aria2-rust-pro-cli/src/http_runtime/build.rs new file mode 100644 index 0000000..0683314 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/http_runtime/build.rs @@ -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, +) -> 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 { + 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() +} diff --git a/crates/aria2-rust-pro-cli/src/http_runtime/execution.rs b/crates/aria2-rust-pro-cli/src/http_runtime/execution.rs new file mode 100644 index 0000000..8d8b7ee --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/http_runtime/execution.rs @@ -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 { + response.total_length() +} + +fn response_completed_length(response: &HttpResponseModel) -> u64 { + response.completed_length() +} + +pub(crate) struct HttpTransferExecution { + pub(crate) response: Option, + pub(crate) retry_count: u32, + pub(crate) retry_attempts: Vec, + pub(crate) planned_ranges: Vec>, + 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( + 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( + 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( + 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::>() + }); + 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( + 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::>() + })); + } + + let mut ordered_results = std::iter::repeat_with(|| None) + .take(task_count) + .collect::>(); + 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( + downloader: &D, + planned_tasks: Vec, + 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( + indexed_work: impl IntoIterator, + task_count: usize, + parallelism: usize, +) -> Vec> { + 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::>(); + 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) +} diff --git a/crates/aria2-rust-pro-cli/src/http_runtime/persist.rs b/crates/aria2-rust-pro-cli/src/http_runtime/persist.rs new file mode 100644 index 0000000..0d1ba09 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/http_runtime/persist.rs @@ -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 { + 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) +} diff --git a/crates/aria2-rust-pro-cli/src/http_runtime/planning.rs b/crates/aria2-rust-pro-cli/src/http_runtime/planning.rs new file mode 100644 index 0000000..2d57ad3 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/http_runtime/planning.rs @@ -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, +} + +pub(crate) fn planned_segment_span(total_length: u64, runtime: &RuntimeConfig) -> Option { + 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( + 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 { + 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 +} diff --git a/crates/aria2-rust-pro-cli/src/lib.rs b/crates/aria2-rust-pro-cli/src/lib.rs new file mode 100644 index 0000000..63bd9b7 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/lib.rs @@ -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 { + 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( + invocation: Invocation, + startup: &StartupProfile, + cli_profile: Option<&ConfigProfile>, + cli_transfer_sources: Option<&[CliTransferSource]>, + downloader: &D, +) -> Result { + 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( + invocation: Invocation, + downloader: &D, +) -> Result { + 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; diff --git a/crates/aria2-rust-pro-cli/src/main.rs b/crates/aria2-rust-pro-cli/src/main.rs new file mode 100644 index 0000000..dee6b4f --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/main.rs @@ -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); + } +} diff --git a/crates/aria2-rust-pro-cli/src/parallel_http_runtime.rs b/crates/aria2-rust-pro-cli/src/parallel_http_runtime.rs new file mode 100644 index 0000000..cd21d8c --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/parallel_http_runtime.rs @@ -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, +} + +type PlannedSegmentBatchTag = usize; + +fn execute_shared_runtime_segment_batches( + 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::>(); + 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( + downloader: &D, + entry: &RegisteredRuntimeEntry, + base_profile: Option<&ConfigProfile>, + index: usize, +) -> Result { + 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( + downloader: &D, + entries: &[RegisteredRuntimeEntry], + base_profile: Option<&ConfigProfile>, + parallel_http_indices: &[usize], + bootstrap_parallelism: usize, +) -> Result, 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::>() + })); + } + + let mut ordered_results = std::iter::repeat_with(|| None) + .take(task_count) + .collect::>(); + 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], + 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( + 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::>(); + 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::>() + }); + + 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::>(); + 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::>(); + 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 + ); + } +} diff --git a/crates/aria2-rust-pro-cli/src/projection.rs b/crates/aria2-rust-pro-cli/src/projection.rs new file mode 100644 index 0000000..6aa1137 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/projection.rs @@ -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 { + 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 { + 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 { + 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 { + 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, +) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + value.trim().parse().ok() +} + +/// Parses a trimmed TCP/UDP port from text. +pub(crate) fn parse_u16_text(value: &str) -> Option { + value.trim().parse().ok() +} + +/// Parses an aria2-style size literal such as `4M`. +pub(crate) fn parse_size_text(value: &str) -> Option { + 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 { + 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 { + 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) -> Option { + 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 { + 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::>(); + 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); + } +} diff --git a/crates/aria2-rust-pro-cli/src/rpc_daemon.rs b/crates/aria2-rust-pro-cli/src/rpc_daemon.rs new file mode 100644 index 0000000..de953a5 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/rpc_daemon.rs @@ -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, + input_entries: Vec, +} + +/// 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, + inputs: Vec, +) -> Result { + 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::() + .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::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, +) -> Result { + 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, +) { + 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(), + }); + } +} diff --git a/crates/aria2-rust-pro-cli/src/runtime_execution.rs b/crates/aria2-rust-pro-cli/src/runtime_execution.rs new file mode 100644 index 0000000..d3f3ee2 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/runtime_execution.rs @@ -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( + dispatcher: &mut InProcessRpcDispatcher, + downloader: &D, + entries: &[RegisteredRuntimeEntry], + base_profile: Option<&ConfigProfile>, + derived_runtime: &RuntimeConfig, +) -> Result<(), CliError> { + let (mut serial_indices, parallel_http_indices) = partition_runtime_entries(entries); + if parallel_http_indices.len() > 1 { + execute_parallel_http_entries( + dispatcher, + downloader, + entries, + base_profile, + derived_runtime, + ¶llel_http_indices, + )?; + } else { + serial_indices.extend(parallel_http_indices); + } + + for index in serial_indices { + let entry = entries + .get(index) + .expect("serial runtime entry index must come from partition_runtime_entries"); + match classify_transfer(&entry.resolved.uri) { + TransferSelection::Magnet | TransferSelection::Torrent => { + execute_bt_runtime_for_gid(dispatcher, &entry.gid)?; + } + _ => match entry.registration_kind { + DispatcherRegistrationKind::Uri => { + execute_transfer_for_uri( + dispatcher, + downloader, + &entry.resolved.uri, + &entry.gid, + entry.resolved.entry_profile.as_ref().or(base_profile), + &entry.resolved.http_session, + &entry.resolved.runtime, + )?; + } + DispatcherRegistrationKind::Torrent => { + execute_bt_runtime_for_gid(dispatcher, &entry.gid)?; + } + }, + } + } + + Ok(()) +} +fn partition_runtime_entries(entries: &[RegisteredRuntimeEntry]) -> (Vec, Vec) { + 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) +} diff --git a/crates/aria2-rust-pro-cli/src/runtime_host.rs b/crates/aria2-rust-pro-cli/src/runtime_host.rs new file mode 100644 index 0000000..c18c7ee --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/runtime_host.rs @@ -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( + invocation: Invocation, + startup: &StartupProfile, + cli_profile: Option<&ConfigProfile>, + cli_transfer_sources: Option<&[CliTransferSource]>, + downloader: &D, +) -> Result { + 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( + config_path: Option, + uris: Vec, + startup: &StartupProfile, + cli_profile: Option<&ConfigProfile>, + cli_transfer_sources: Option<&[CliTransferSource]>, + downloader: &D, +) -> Result { + let context = build_runtime_input_context( + config_path, + uris, + startup, + cli_profile, + cli_transfer_sources, + downloader, + )?; + let base_profile = context.effective_profile.as_ref(); + let mut dispatcher = + super::InProcessRpcDispatcher::with_runtime(context.derived_runtime.clone()); + let registered_entries = register_runtime_entries( + &mut dispatcher, + downloader, + context.resolved_entries, + base_profile, + )?; + execute_registered_transfers( + &mut dispatcher, + downloader, + ®istered_entries, + base_profile, + &context.derived_runtime, + )?; + let summary = collect_runtime_execution_summary(&mut dispatcher, ®istered_entries)?; + + Ok(RuntimeReport { + accepted_uri_count: registered_entries.len(), + tracked_download_count: summary.tracked_download_count, + completed_download_count: summary.completed_download_count, + first_gid: summary.first_gid, + first_status: summary.first_status, + first_total_length: summary.first_total_length, + first_completed_length: summary.first_completed_length, + first_connections: summary.first_connections, + recognized_schemes: context.recognized_schemes, + transfer_kinds: context.transfer_kinds, + config_report: context.config_report, + derived_runtime: context.derived_runtime, + http_session: context.http_session, + control_file_version_major: ControlFileVersion::CURRENT.major(), + first_bt_status: summary.first_bt_status, + }) +} diff --git a/crates/aria2-rust-pro-cli/src/runtime_planning.rs b/crates/aria2-rust-pro-cli/src/runtime_planning.rs new file mode 100644 index 0000000..8fd96eb --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/runtime_planning.rs @@ -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, + pub(super) effective_profile: Option, + pub(super) recognized_schemes: Vec, + pub(super) transfer_kinds: Vec, + pub(super) derived_runtime: RuntimeConfig, + pub(super) http_session: HttpSessionModel, + pub(super) resolved_entries: Vec, +} + +#[derive(Clone, Debug)] +pub(super) struct ResolvedRuntimeEntry { + pub(super) entry: TransferInputEntry, + pub(super) entry_profile: Option, + 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( + config_path: Option, + uris: Vec, + startup: &StartupProfile, + cli_profile: Option<&ConfigProfile>, + cli_transfer_sources: Option<&[CliTransferSource]>, + downloader: &D, +) -> Result { + 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::>(); + let transfer_kinds = input_entries + .iter() + .filter_map(|entry| entry.uris.first()) + .map(String::as_str) + .map(classify_transfer) + .collect::>(); + + 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( + dispatcher: &mut InProcessRpcDispatcher, + downloader: &D, + entries: Vec, + base_profile: Option<&ConfigProfile>, +) -> Result, 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( + input_entries: &[TransferInputEntry], + startup: &StartupProfile, + profile: Option<&ConfigProfile>, + downloader: &D, +) -> Result, 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, 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 +} diff --git a/crates/aria2-rust-pro-cli/src/runtime_summary.rs b/crates/aria2-rust-pro-cli/src/runtime_summary.rs new file mode 100644 index 0000000..55352f5 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/runtime_summary.rs @@ -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, + pub(super) tracked_download_count: usize, + pub(super) completed_download_count: usize, + pub(super) first_status: Option, + pub(super) first_total_length: Option, + pub(super) first_completed_length: Option, + pub(super) first_connections: Option, + pub(super) first_bt_status: Option, +} + +pub(super) fn collect_runtime_execution_summary( + dispatcher: &mut InProcessRpcDispatcher, + entries: &[RegisteredRuntimeEntry], +) -> Result { + 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) +} diff --git a/crates/aria2-rust-pro-cli/src/tests.rs b/crates/aria2-rust-pro-cli/src/tests.rs new file mode 100644 index 0000000..4e84a1c --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests.rs @@ -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 { + fn bencode_bytes(value: &[u8]) -> Vec { + let mut encoded = format!("{}:", value.len()).into_bytes(); + encoded.extend_from_slice(value); + encoded + } + + fn bencode_int(value: u64) -> Vec { + 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#" + + +900150983cd24fb0d6963f7d28e17f72 +http://example.com/alpha.bin + + +http://example.com/beta.bin + +"#, + ) + .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>>>, + requests: Arc>>, +} + +struct LocalFtpTestServer { + control_port: u16, + join: Option>, +} + +impl LocalFtpTestServer { + fn spawn(payload: Vec) -> 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 { + 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 { + 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 { + 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) -> Result { + 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, + expected: u16, + ) -> Result { + let line = Self::read_response_line(reader)?; + let code = line + .get(0..3) + .and_then(|digits| digits.parse::().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::) + .collect::, _>>() + .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| 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses)), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + fn recorded_requests(&self) -> Vec { + self.requests.lock().expect("lock should work").clone() + } +} + +impl Downloader for SequencedHttpDownloader { + fn start_http_transfer( + &self, + task: &aria2_rust_pro_protocol::HttpTransferTaskModel, + ) -> Result { + 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 { + 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 { + 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>>, + requests: Arc>>, + active_calls: Arc, + max_concurrent_calls: Arc, + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/crates/aria2-rust-pro-cli/src/tests/command_surface.rs b/crates/aria2-rust-pro-cli/src/tests/command_surface.rs new file mode 100644 index 0000000..1494d13 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/command_surface.rs @@ -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#" + + +13 +http://example.com/file.bin + +"#, + ) + .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#" + + + + + +14 +http://example.com/first.bin +http://example.com/preferred.bin + +"#, + ) + .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#" + + +11 +http://example.com/remote.bin + +"#, + ); + 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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface.rs new file mode 100644 index 0000000..5f9f190 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface.rs @@ -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; diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/bt_runtime_and_tracker.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/bt_runtime_and_tracker.rs new file mode 100644 index 0000000..c684f1c --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/bt_runtime_and_tracker.rs @@ -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"); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/cli_overrides_and_proxy.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/cli_overrides_and_proxy.rs new file mode 100644 index 0000000..1e143fa --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/cli_overrides_and_proxy.rs @@ -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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/config_and_protocol_surface.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/config_and_protocol_surface.rs new file mode 100644 index 0000000..a141b67 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/config_and_protocol_surface.rs @@ -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" + ); + } +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/http_runtime_and_checksum.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/http_runtime_and_checksum.rs new file mode 100644 index 0000000..247d0fb --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/http_runtime_and_checksum.rs @@ -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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/input_file_and_source_order.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/input_file_and_source_order.rs new file mode 100644 index 0000000..a9b1a68 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/input_file_and_source_order.rs @@ -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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/integration_surface/rpc_pressure_and_command_surface.rs b/crates/aria2-rust-pro-cli/src/tests/integration_surface/rpc_pressure_and_command_surface.rs new file mode 100644 index 0000000..570066f --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/integration_surface/rpc_pressure_and_command_surface.rs @@ -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::>(); + + 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" + ); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution.rs new file mode 100644 index 0000000..59fc8db --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution.rs @@ -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; diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution/checksum_completion.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/checksum_completion.rs new file mode 100644 index 0000000..564fa2f --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/checksum_completion.rs @@ -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)); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution/live_http_connector.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/live_http_connector.rs new file mode 100644 index 0000000..770af91 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/live_http_connector.rs @@ -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)> { + 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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution/retry_and_partial.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/retry_and_partial.rs new file mode 100644 index 0000000..461b871 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/retry_and_partial.rs @@ -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::>(); + 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); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_parallelism.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_parallelism.rs new file mode 100644 index 0000000..319daf9 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_parallelism.rs @@ -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::>(); + + 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::>(); + 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::>(); + + 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::>(); + + 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" + ); +} diff --git a/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_planning.rs b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_planning.rs new file mode 100644 index 0000000..4758bcc --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_planning.rs @@ -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::>(); + 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::>(); + 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); +} diff --git a/crates/aria2-rust-pro-cli/src/transfer_resolution.rs b/crates/aria2-rust-pro-cli/src/transfer_resolution.rs new file mode 100644 index 0000000..46f615f --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/transfer_resolution.rs @@ -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, + /// Optional password from URI user info. + pub(super) password: Option, + /// 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 { + 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, 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, 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::>(); + + 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, 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 { + 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( + entry: &TransferInputEntry, + downloader: &D, + session: &HttpSessionModel, + runtime: &RuntimeConfig, +) -> Result, 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| { + entries + .into_iter() + .map(|resolved| TransferInputEntry { + uris: resolved.uris, + implied_profile: resolved.implied_profile, + profile: shared_profile.clone(), + }) + .collect::>() + }; + + 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( + downloader: &D, + uri: &str, + profile: Option<&ConfigProfile>, + http_session: &HttpSessionModel, + derived_runtime: &RuntimeConfig, +) -> Result, 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( + 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::>(); + 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() +} diff --git a/crates/aria2-rust-pro-cli/src/transfer_runtime.rs b/crates/aria2-rust-pro-cli/src/transfer_runtime.rs new file mode 100644 index 0000000..20d5778 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/transfer_runtime.rs @@ -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, 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 { + 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, +) -> 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, +) -> 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( + dispatcher: &mut InProcessRpcDispatcher, + downloader: &D, + uri: &str, + gid: &str, + profile: Option<&ConfigProfile>, + http_session: &HttpSessionModel, + derived_runtime: &RuntimeConfig, +) -> Result { + 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), + } +} diff --git a/crates/aria2-rust-pro-cli/src/types.rs b/crates/aria2-rust-pro-cli/src/types.rs new file mode 100644 index 0000000..a88c414 --- /dev/null +++ b/crates/aria2-rust-pro-cli/src/types.rs @@ -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, + /// 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, + /// Ordered CLI transfer sources so mixed positional URIs and `--input-file` + /// entries can preserve argv order. + pub(crate) cli_transfer_sources: Vec, +} + +#[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, + /// Inputs that should be registered before the daemon starts serving. + inputs: Vec, + }, + /// Print the version banner. + PrintVersion, + /// Print help text, optionally filtered by a query term. + PrintHelp { + /// Optional help topic or filter string. + query: Option, + }, + /// 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, + }, + /// Execute or stage one or more transfer inputs. + Run { + /// Optional config file path supplied on the command line. + config_path: Option, + /// Ordered transfer inputs supplied on the command line. + uris: Vec, + }, +} + +#[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, + /// First visible status from `aria2.tellStatus`, when available. + pub first_status: Option, + /// First visible total length from `aria2.tellStatus`, when available. + pub first_total_length: Option, + /// First visible completed length from `aria2.tellStatus`, when available. + pub first_completed_length: Option, + /// First visible connection count from `aria2.tellStatus`, when available. + pub first_connections: Option, + /// Recognized transfer schemes extracted from the provided inputs. + pub recognized_schemes: Vec, + /// High-level transfer classifications derived from the inputs. + pub transfer_kinds: Vec, + /// Loaded config summary, when a config file participated in execution. + pub config_report: Option, + /// 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, +} + +#[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, + /// Implied per-download defaults synthesized from the source document. + pub(crate) implied_profile: Option, + /// Optional per-entry overrides originating from input-file indentation blocks. + pub(crate) profile: Option, +} + +#[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, + /// Whether the download still represents metadata-only state. + pub metadata_only: Option, + /// Canonical magnet URI, when available. + pub magnet_uri: Option, + /// Number of announce-list tiers surfaced by the runtime. + pub announce_list_tier_count: Option, + /// Whether the download is currently seeding. + pub seeder: Option, + /// Current visible seeder count. + pub num_seeders: Option, + /// Current share ratio string. + pub share_ratio: Option, + /// In-progress share ratio projection. + pub share_ratio_progress: Option, + /// Remaining share ratio projection. + pub share_ratio_remaining: Option, + /// Current share time in seconds. + pub share_time: Option, +} diff --git a/crates/aria2-rust-pro-compat/Cargo.toml b/crates/aria2-rust-pro-compat/Cargo.toml new file mode 100644 index 0000000..d4daac5 --- /dev/null +++ b/crates/aria2-rust-pro-compat/Cargo.toml @@ -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 diff --git a/crates/aria2-rust-pro-compat/src/compat.rs b/crates/aria2-rust-pro-compat/src/compat.rs new file mode 100644 index 0000000..63a889e --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/compat.rs @@ -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, + /// Additional notes that summarize current caveats. + pub notes: Vec, +} + +/// Returns the static compatibility notes for the current rewrite snapshot. +#[must_use] +pub fn compatibility_notes() -> Vec { + 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(), + } +} diff --git a/crates/aria2-rust-pro-compat/src/config.rs b/crates/aria2-rust-pro-compat/src/config.rs new file mode 100644 index 0000000..ae84352 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/config.rs @@ -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, +} +/// Parsed config directives with source metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigAst { + /// Ordered directives found in the source input. + pub directives: Vec, + /// 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, + /// 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, + /// 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, 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, 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, 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, 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 { + 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 { + 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"); + } +} diff --git a/crates/aria2-rust-pro-compat/src/help.rs b/crates/aria2-rust-pro-compat/src/help.rs new file mode 100644 index 0000000..5798eea --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/help.rs @@ -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, +} + +/// 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, +} + +/// Full help document returned by compat helpers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HelpDocument { + /// Usage sections rendered before option details. + pub usage: Vec, + /// Detail sections rendered after usage. + pub sections: Vec, +} + +/// 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, + /// Optional default value summary. + default_value: Option, + /// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> String { + let mut text = String::new(); + if let Some(usage_line) = document + .usage + .first() + .and_then(|section| section.lines.first()) + { + text.push_str("Usage: "); + text.push_str(usage_line); + text.push('\n'); + } + match query { + None | Some(HelpQuery::All) => text.push_str("Printing all options.\n"), + Some(HelpQuery::Tag(tag)) => { + let _ = writeln!(text, "Printing options tagged with \"{tag}\"."); + } + Some(HelpQuery::Keyword(keyword)) => { + let _ = writeln!(text, "Printing options whose name includes \"{keyword}\"."); + } + } + for section in &document.sections { + text.push_str(section.name); + text.push_str(":\n"); + for block in §ion.body { + text.push_str(block); + if !block.ends_with('\n') { + text.push('\n'); + } + } + } + text.push_str("Refer to man page for more information.\n"); + text +} + +#[cfg(test)] +mod tests { + use super::{cli_version_text, help_text, help_text_for_query}; + + #[test] + fn version_text_uses_aria2_style_banner() { + let version = cli_version_text(); + assert!(version.contains("aria2 version 1.37.0")); + assert!(version.contains("Rust rewrite package: aria2-rust-pro")); + assert!(version.contains("Enabled Features:")); + } + + #[test] + fn help_text_uses_aria2_style_usage_and_options() { + let help = help_text(); + assert!(help.starts_with("Usage: aria2c [OPTIONS]")); + assert!(help.contains("Printing all options.")); + assert!(help.contains("Options:")); + assert!(help.contains("-h, --help[=TAG|KEYWORD]")); + assert!(help.contains("--retry-on-400[=true|false]")); + assert!(help.contains("Refer to man page for more information.")); + } + + #[test] + fn help_text_for_tag_filters_to_matching_entries() { + let help = help_text_for_query(Some("#http")); + assert!(help.contains("Printing options tagged with \"#http\".")); + assert!(help.contains("--user-agent=VALUE, -U")); + assert!(help.contains("--retry-on-400[=true|false]")); + assert!(!help.contains("--bt-save-metadata[=true|false]")); + } + + #[test] + fn help_text_for_keyword_filters_to_matching_entries() { + let help = help_text_for_query(Some("rpc")); + assert!(help.contains("Printing options whose name includes \"rpc\".")); + assert!(help.contains("--rpc-listen-port=PORT")); + assert!(help.contains("--rpc-secret=VALUE")); + assert!(!help.contains("--bt-tracker=URI,...")); + } +} diff --git a/crates/aria2-rust-pro-compat/src/lib.rs b/crates/aria2-rust-pro-compat/src/lib.rs new file mode 100644 index 0000000..e15e8a5 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/lib.rs @@ -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, + }, +} + +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(); + } + } +} diff --git a/crates/aria2-rust-pro-compat/src/options.rs b/crates/aria2-rust-pro-compat/src/options.rs new file mode 100644 index 0000000..0f1b20e --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options.rs @@ -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; diff --git a/crates/aria2-rust-pro-compat/src/options/model.rs b/crates/aria2-rust-pro-compat/src/options/model.rs new file mode 100644 index 0000000..1a04c50 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/model.rs @@ -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 { + 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 + } +} diff --git a/crates/aria2-rust-pro-compat/src/options/query.rs b/crates/aria2-rust-pro-compat/src/options/query.rs new file mode 100644 index 0000000..6b36fa6 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/query.rs @@ -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 { + option_spec(name).map(|s| format!("ARIA2_{}", s.name.replace('-', "_").to_ascii_uppercase())) +} diff --git a/crates/aria2-rust-pro-compat/src/options/registry.rs b/crates/aria2-rust-pro-compat/src/options/registry.rs new file mode 100644 index 0000000..c9ce26d --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/registry.rs @@ -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> = LazyLock::new(build_option_specs); diff --git a/crates/aria2-rust-pro-compat/src/options/registry/compatibility_extension_entries.rs b/crates/aria2-rust-pro-compat/src/options/registry/compatibility_extension_entries.rs new file mode 100644 index 0000000..18ed077 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/registry/compatibility_extension_entries.rs @@ -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", + }, + }, +]; diff --git a/crates/aria2-rust-pro-compat/src/options/registry/foundational_entries.rs b/crates/aria2-rust-pro-compat/src/options/registry/foundational_entries.rs new file mode 100644 index 0000000..8f904d7 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/registry/foundational_entries.rs @@ -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", + }, + }, +]; diff --git a/crates/aria2-rust-pro-compat/src/options/registry/hooks_and_rpc_entries.rs b/crates/aria2-rust-pro-compat/src/options/registry/hooks_and_rpc_entries.rs new file mode 100644 index 0000000..cf1cffb --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/registry/hooks_and_rpc_entries.rs @@ -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", + }, + }, +]; diff --git a/crates/aria2-rust-pro-compat/src/options/registry/transfer_tuning_entries.rs b/crates/aria2-rust-pro-compat/src/options/registry/transfer_tuning_entries.rs new file mode 100644 index 0000000..519b3fd --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/registry/transfer_tuning_entries.rs @@ -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", + }, + }, +]; diff --git a/crates/aria2-rust-pro-compat/src/options/reserved.rs b/crates/aria2-rust-pro-compat/src/options/reserved.rs new file mode 100644 index 0000000..4366cc7 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/reserved.rs @@ -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", + }, +]; diff --git a/crates/aria2-rust-pro-compat/src/options/tests.rs b/crates/aria2-rust-pro-compat/src/options/tests.rs new file mode 100644 index 0000000..2b55d82 --- /dev/null +++ b/crates/aria2-rust-pro-compat/src/options/tests.rs @@ -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::>(); + let per_names = per_download_option_specs() + .into_iter() + .map(|spec| spec.name) + .collect::>(); + + 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::>(); + let global_names = global_option_specs() + .into_iter() + .map(|spec| spec.name) + .collect::>(); + let per_names = per_download_option_specs() + .into_iter() + .map(|spec| spec.name) + .collect::>(); + + for (label, names) in [ + ("live", live_names), + ("global", global_names), + ("per-download", per_names), + ] { + let unique = names + .iter() + .copied() + .collect::>(); + assert_eq!( + unique.len(), + names.len(), + "{label} option projection should not contain duplicate canonical names: {names:?}" + ); + } +} diff --git a/crates/aria2-rust-pro-core/Cargo.toml b/crates/aria2-rust-pro-core/Cargo.toml new file mode 100644 index 0000000..d6ff1ee --- /dev/null +++ b/crates/aria2-rust-pro-core/Cargo.toml @@ -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 diff --git a/crates/aria2-rust-pro-core/src/engine.rs b/crates/aria2-rust-pro-core/src/engine.rs new file mode 100644 index 0000000..eaf3fe0 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine.rs @@ -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, +} + +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) -> 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 { + 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 + '_ { + 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, + /// 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) -> 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, value: impl Into) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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, + 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; diff --git a/crates/aria2-rust-pro-core/src/engine/bt_runtime.rs b/crates/aria2-rust-pro-core/src/engine/bt_runtime.rs new file mode 100644 index 0000000..27e81ea --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/bt_runtime.rs @@ -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, + ) -> 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 { + 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 { + 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 { + 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, + ) -> Result { + 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 { + 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, + ) -> Result { + 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, + seeders: Option, + leechers: Option, + ) -> 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(()) + } +} diff --git a/crates/aria2-rust-pro-core/src/engine/inspection.rs b/crates/aria2-rust-pro-core/src/engine/inspection.rs new file mode 100644 index 0000000..5384d24 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/inspection.rs @@ -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, + /// Effective per-download upload cap after global and local policy are merged. + pub effective_upload_limit: Option, + /// 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, +} + +/// 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, + /// Global per-download download limit configured in the runtime. + pub max_download_limit: Option, + /// Global overall upload limit configured in the runtime. + pub max_overall_upload_limit: Option, + /// Global per-download upload limit configured in the runtime. + pub max_upload_limit: Option, + /// 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, +} + +impl DownloadEngine { + /// Returns the current status of a specific download. + pub fn tell_status(&self, gid: DownloadId) -> Result { + 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 { + 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, Option) { + 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, right: Option) -> Option { + 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 { + 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::(); + 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 { + 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), + ) +} diff --git a/crates/aria2-rust-pro-core/src/engine/queue.rs b/crates/aria2-rust-pro-core/src/engine/queue.rs new file mode 100644 index 0000000..d001228 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/queue.rs @@ -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 { + 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::>(); + 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); + } +} diff --git a/crates/aria2-rust-pro-core/src/engine/scheduling.rs b/crates/aria2-rust-pro-core/src/engine/scheduling.rs new file mode 100644 index 0000000..e15dcfb --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/scheduling.rs @@ -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 { + 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::>(); + 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::>(); + 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 { + 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 { + 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 +} diff --git a/crates/aria2-rust-pro-core/src/engine/session_persistence.rs b/crates/aria2-rust-pro-core/src/engine/session_persistence.rs new file mode 100644 index 0000000..4f203d4 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/session_persistence.rs @@ -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 { + 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 { + 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::>() + .join(","), + OptionValue::Empty => String::default(), + } +} + +/// Restores request-group runtime metadata from persisted session metadata fields. +fn restore_group_metadata(group: &mut RequestGroup, metadata: Option>) { + let Some(metadata) = metadata else { + return; + }; + let mut bt = BtRuntimeState::default(); + let mut bt_seen = false; + let mut bt_files: BTreeMap = 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::() { + group.set_num_connections(parsed); + } + true + } + "download_speed" => { + if let Ok(parsed) = value.parse::() { + group.set_download_speed(parsed); + } + true + } + "upload_length" => { + if let Ok(parsed) = value.parse::() { + group.set_upload_length(parsed); + } + true + } + "completed_length" => { + if let Ok(parsed) = value.parse::() { + group.set_completed_length(parsed); + } + true + } + "retry_count" => { + if let Ok(parsed) = value.parse::() { + 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, + 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::().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, + 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::() 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::() { + file.length = parsed; + } + } + "selected" => { + file.selected = value.parse::().unwrap_or(false); + } + "piece_offset" => { + if let Ok(parsed) = value.parse::() { + 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::>() + .join(",") +} + +/// Decodes retry attempts from the compact session-metadata string. +fn decode_retry_attempts_metadata(raw: &str) -> Vec { + 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 { + 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 { + 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, + } +} diff --git a/crates/aria2-rust-pro-core/src/engine/tests.rs b/crates/aria2-rust-pro-core/src/engine/tests.rs new file mode 100644 index 0000000..c806163 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/engine/tests.rs @@ -0,0 +1,1527 @@ +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use aria2_rust_pro_storage::{load_session_file, save_session_file}; + +use crate::{ + engine::{DownloadEngine, QueuePositionMode}, + error::{CoreError, Result}, + piece::{PieceId, PieceState}, + request::{ + BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DownloadStatus, RequestGroup, + ResumeState, SegmentState, + }, + runtime::RuntimeConfig, + scheduler::ScheduleDecision, + session::SaveSessionTarget, +}; + +fn temp_session_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be monotonic enough for test naming") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "aria2-rust-pro-core-test-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temp dir should be creatable"); + root.join(name) +} + +#[test] +fn engine_load_session_restores_saved_options() -> Result<()> { + let mut engine = DownloadEngine::new(); + let path = PathBuf::from("engine-session.txt"); + + engine.set_option("max-download-result", "500"); + engine.save_session(SaveSessionTarget::Path(path.clone()))?; + + engine.set_option("max-download-result", "10"); + engine.load_session(SaveSessionTarget::Path(path))?; + + assert_eq!( + engine + .session() + .global_options() + .get(&"max-download-result".into()) + .and_then(|value| value.as_text()), + Some("500") + ); + Ok(()) +} + +#[test] +fn resume_moves_paused_download_back_to_waiting() { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("https://example.org/resume.bin").gid(); + engine.pause(gid).expect("pause should succeed"); + engine.resume(gid).expect("resume should succeed"); + + let group = engine + .registry() + .get(gid) + .expect("group should still exist after resume"); + assert_eq!(group.status(), &DownloadStatus::Waiting); +} + +#[test] +fn resume_rejects_non_paused_downloads() { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("https://example.org/not-paused.bin").gid(); + + assert_eq!( + engine.resume(gid), + Err(CoreError::InvalidState("download cannot be unpaused now")) + ); +} + +#[test] +fn engine_save_session_writes_session_and_control_files() -> Result<()> { + let session_path = temp_session_path("session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut engine = DownloadEngine::with_runtime(runtime); + let gid = engine.add_uri("https://example.org/files/ubuntu.iso").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_option("dir", "D:/downloads"); + group.set_option("out", "ubuntu.iso"); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Downloading); + + engine.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let session_file = load_session_file(&session_path).expect("session file should load"); + assert_eq!(session_file.entries.len(), 1); + let entry = session_file + .entries + .first() + .expect("session file should contain one entry"); + assert_eq!(entry.uri, "https://example.org/files/ubuntu.iso"); + assert_eq!( + entry.uris, + vec!["https://example.org/files/ubuntu.iso".to_owned()] + ); + assert_eq!( + entry.target_path, + PathBuf::from("D:/downloads").join("ubuntu.iso") + ); + let control_path = entry + .metadata_path + .clone() + .expect("control metadata path should be present"); + assert!(control_path.exists()); + + let control = aria2_rust_pro_storage::read_aria2_control_file(&control_path) + .expect("control file should load"); + assert_eq!( + control + .files + .first() + .expect("control file should contain one file entry") + .path, + PathBuf::from("D:/downloads").join("ubuntu.iso") + ); + assert_eq!(control.piece_states.len(), 2); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn engine_save_and_load_session_round_trips_multiple_uris() -> Result<()> { + let session_path = temp_session_path("multi-uri-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("https://example.org/rebuild/file.bin").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.context_mut().replace_uris(vec![ + "https://example.org/rebuild/file.bin".to_owned(), + "https://mirror1.example.org/rebuild/file.bin".to_owned(), + "https://mirror2.example.org/rebuild/file.bin".to_owned(), + ]); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let session_file = load_session_file(&session_path).expect("session file should load"); + assert_eq!( + session_file + .entries + .first() + .expect("session file should contain one entry") + .uris, + vec![ + "https://example.org/rebuild/file.bin".to_owned(), + "https://mirror1.example.org/rebuild/file.bin".to_owned(), + "https://mirror2.example.org/rebuild/file.bin".to_owned(), + ] + ); + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + assert_eq!( + loaded.uris(), + &[ + "https://example.org/rebuild/file.bin".to_owned(), + "https://mirror1.example.org/rebuild/file.bin".to_owned(), + "https://mirror2.example.org/rebuild/file.bin".to_owned(), + ] + ); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn engine_load_session_rebuilds_registry_from_saved_file() -> Result<()> { + let session_path = temp_session_path("reload-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("https://example.org/rebuild/file.bin").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_option("dir", "C:/aria2-work"); + group.set_option("out", "file.bin"); + group.set_status(DownloadStatus::Paused); + group.set_piece_state(PieceId(3), PieceState::Verified); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + + assert_eq!(reader.registry().len(), 1); + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + assert_eq!(loaded.uri(), "https://example.org/rebuild/file.bin"); + assert_eq!(loaded.status(), &DownloadStatus::Paused); + assert_eq!( + loaded + .options() + .get(&"out".into()) + .and_then(|value| value.as_text()), + Some("file.bin") + ); + assert_eq!(loaded.piece_state(PieceId(3)), Some(PieceState::Verified)); + assert_eq!(reader.progress_snapshot(loaded_gid)?.completed_length, 1024); + assert_eq!(reader.progress_snapshot(loaded_gid)?.total_length, 4096); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn progress_snapshot_derives_lengths_and_eta_from_runtime_state() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("https://example.org/runtime.bin").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_piece_length(1024); + group.set_total_length(3072); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Downloading); + group.set_completed_length(1536); + group.set_download_speed(512); + group.set_upload_length(128); + group.set_num_connections(3); + group.set_status(DownloadStatus::Active); + + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(snapshot.total_length, 3072); + assert_eq!(snapshot.completed_length, 1536); + assert_eq!(snapshot.download_speed, 512); + assert_eq!(snapshot.upload_length, 128); + assert_eq!(snapshot.num_connections, 3); + assert_eq!(snapshot.eta_seconds, Some(3)); + Ok(()) +} + +#[test] +fn global_stat_sums_snapshot_lengths_and_speeds() { + let mut engine = DownloadEngine::new(); + let gid1 = engine.add_uri("https://example.org/a.bin").gid(); + let gid2 = engine.add_uri("https://example.org/b.bin").gid(); + + let group1 = engine.handle_mut(gid1).expect("group 1 should exist"); + group1.set_piece_length(1024); + group1.set_total_length(2048); + group1.set_piece_state(PieceId(0), PieceState::Verified); + group1.set_completed_length(1536); + group1.set_download_speed(100); + group1.set_status(DownloadStatus::Active); + + let group2 = engine.handle_mut(gid2).expect("group 2 should exist"); + group2.set_piece_length(1024); + group2.set_total_length(1024); + group2.set_piece_state(PieceId(0), PieceState::Verified); + group2.set_completed_length(1024); + group2.set_download_speed(50); + group2.set_upload_length(25); + group2.set_upload_speed(25); + group2.set_status(DownloadStatus::Paused); + + let stat = engine.get_global_stat(); + assert_eq!(stat.total_length, 3072); + assert_eq!(stat.completed_length, 2560); + assert_eq!(stat.download_speed, 150); + assert_eq!(stat.upload_speed, 25); +} + +#[test] +fn tell_waiting_includes_paused_and_tell_stopped_excludes_paused() { + let mut engine = DownloadEngine::new(); + let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid(); + let paused_gid = engine.add_uri("https://example.org/paused.bin").gid(); + let complete_gid = engine.add_uri("https://example.org/complete.bin").gid(); + + engine.pause(paused_gid).expect("pause should succeed"); + engine + .complete(complete_gid) + .expect("complete should succeed"); + + let waiting = engine + .tell_waiting() + .into_iter() + .map(super::DownloadHandle::gid) + .collect::>(); + assert_eq!(waiting.len(), 2); + assert!(waiting.contains(&waiting_gid)); + assert!(waiting.contains(&paused_gid)); + + let stopped = engine + .tell_stopped() + .into_iter() + .map(super::DownloadHandle::gid) + .collect::>(); + assert_eq!(stopped, vec![complete_gid]); +} + +#[test] +fn change_position_reorders_waiting_queue_with_set_cur_and_end_modes() { + let mut engine = DownloadEngine::new(); + let gid0 = engine.add_uri("https://example.org/0.bin").gid(); + let gid1 = engine.add_uri("https://example.org/1.bin").gid(); + let gid2 = engine.add_uri("https://example.org/2.bin").gid(); + let gid3 = engine.add_uri("https://example.org/3.bin").gid(); + let gid4 = engine.add_uri("https://example.org/4.bin").gid(); + + assert_eq!( + engine + .change_position(gid1, 4, QueuePositionMode::Set) + .expect("set move should succeed"), + 4 + ); + assert_eq!( + engine + .change_position(gid2, 3, QueuePositionMode::Set) + .expect("set move should succeed"), + 3 + ); + assert_eq!( + engine + .change_position(gid2, 1, QueuePositionMode::Set) + .expect("set move should succeed"), + 1 + ); + assert_eq!( + engine + .change_position(gid1, 1, QueuePositionMode::Cur) + .expect("cur move should succeed"), + 4 + ); + assert_eq!( + engine + .change_position(gid0, -2, QueuePositionMode::End) + .expect("end move should succeed"), + 2 + ); + + let waiting = engine + .tell_waiting() + .into_iter() + .map(super::DownloadHandle::gid) + .collect::>(); + assert_eq!(waiting, vec![gid2, gid3, gid0, gid4, gid1]); +} + +#[test] +fn pause_active_download_moves_it_to_front_of_waiting_queue() { + let mut engine = DownloadEngine::new(); + let gid0 = engine.add_uri("https://example.org/0.bin").gid(); + let gid1 = engine.add_uri("https://example.org/1.bin").gid(); + let _ = engine.schedule_once(); + + engine.pause(gid0).expect("pause should succeed"); + + let waiting = engine + .tell_waiting() + .into_iter() + .map(super::DownloadHandle::gid) + .collect::>(); + assert_eq!(waiting, vec![gid0, gid1]); +} + +#[test] +fn pause_rejects_already_paused_downloads() { + let mut engine = DownloadEngine::new(); + let gid = engine + .add_uri("https://example.org/already-paused.bin") + .gid(); + engine.pause(gid).expect("initial pause should succeed"); + + assert_eq!( + engine.pause(gid), + Err(CoreError::InvalidState("download cannot be paused now")) + ); +} + +#[test] +fn remove_download_result_only_removes_stopped_entries() { + let mut engine = DownloadEngine::new(); + let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid(); + let complete_gid = engine.add_uri("https://example.org/complete.bin").gid(); + let error_gid = engine.add_uri("https://example.org/error.bin").gid(); + + engine + .complete(complete_gid) + .expect("complete transition should succeed"); + engine + .fail(error_gid) + .expect("error transition should succeed"); + + engine + .remove_download_result(complete_gid) + .expect("stopped result should be removable"); + + assert!(engine.registry().get(complete_gid).is_none()); + assert!(engine.registry().get(error_gid).is_some()); + assert!(engine.registry().get(waiting_gid).is_some()); + assert_eq!( + engine.remove_download_result(waiting_gid), + Err(CoreError::InvalidState( + "download result is not available for active or waiting downloads", + )) + ); +} + +#[test] +fn purge_download_results_removes_only_stopped_entries() { + let mut engine = DownloadEngine::new(); + let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid(); + let paused_gid = engine.add_uri("https://example.org/paused.bin").gid(); + let complete_gid = engine.add_uri("https://example.org/complete.bin").gid(); + let removed_gid = engine.add_uri("https://example.org/removed.bin").gid(); + let error_gid = engine.add_uri("https://example.org/error.bin").gid(); + + engine.pause(paused_gid).expect("pause should succeed"); + engine + .complete(complete_gid) + .expect("complete transition should succeed"); + engine + .remove(removed_gid) + .expect("remove transition should succeed"); + engine + .fail(error_gid) + .expect("error transition should succeed"); + + assert_eq!(engine.purge_download_results(), 3); + assert!(engine.registry().get(waiting_gid).is_some()); + assert!(engine.registry().get(paused_gid).is_some()); + assert!(engine.registry().get(complete_gid).is_none()); + assert!(engine.registry().get(removed_gid).is_none()); + assert!(engine.registry().get(error_gid).is_none()); +} + +#[test] +fn progress_snapshot_prefers_verified_piece_progress_when_larger() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine + .add_uri("https://example.org/verified-dominates.bin") + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_completed_length(512); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Verified); + + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(snapshot.completed_length, 2048); + Ok(()) +} + +#[test] +fn save_and_load_session_round_trips_retry_and_completed_metrics() -> Result<()> { + let session_path = temp_session_path("metrics-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("https://example.org/metrics.bin").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_completed_length(3072); + group.increment_retry_count(); + group.increment_retry_count(); + group.set_status(DownloadStatus::Active); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + assert_eq!(loaded.retry_count(), 2); + assert_eq!(loaded.completed_length(), 3072); + assert_eq!(reader.progress_snapshot(loaded_gid)?.completed_length, 3072); + assert_eq!(loaded.status(), &DownloadStatus::Active); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn save_and_load_session_round_trips_bt_selected_file_state() -> Result<()> { + let session_path = temp_session_path("bt-selected-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("magnet:?xt=urn:btih:abcdef").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_bt(BtRuntimeState { + info_hash: "abcdef".to_owned(), + name: Some("linux-iso-pack".to_owned()), + metadata_only: false, + files: vec![ + BtFileInfo { + path: "disc1.iso".to_owned(), + length: 1024, + piece_offset: Some(0), + selected: true, + }, + BtFileInfo { + path: "disc2.iso".to_owned(), + length: 2048, + piece_offset: Some(1024), + selected: false, + }, + ], + ..BtRuntimeState::default() + }); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + let bt = loaded.bt().expect("bt runtime state should round-trip"); + assert_eq!(bt.info_hash, "abcdef"); + assert_eq!(bt.files.len(), 2); + assert!( + bt.files + .first() + .expect("bt file list should contain the first file") + .selected + ); + assert!( + !bt.files + .get(1) + .expect("bt file list should contain the second file") + .selected + ); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn save_and_load_session_round_trips_paused_bt_group_state() -> Result<()> { + let session_path = temp_session_path("bt-paused-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("magnet:?xt=urn:btih:123456").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Paused); + group.set_bt(BtRuntimeState { + info_hash: "123456".to_owned(), + metadata_only: true, + files: vec![BtFileInfo { + path: "metadata.part".to_owned(), + length: 512, + piece_offset: None, + selected: true, + }], + ..BtRuntimeState::default() + }); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + assert_eq!(loaded.status(), &DownloadStatus::Paused); + let bt = loaded.bt().expect("bt runtime state should round-trip"); + assert_eq!(bt.info_hash, "123456"); + assert_eq!(bt.files.len(), 1); + let first_file = bt + .files + .first() + .expect("bt file list should contain the metadata placeholder"); + assert_eq!(first_file.path, "metadata.part"); + assert!(first_file.selected); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn load_session_recovers_partial_progress_from_control_file_when_session_metadata_lacks_it() +-> Result<()> { + let session_path = temp_session_path("control-resume-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer + .add_uri("https://example.org/control-resume.bin") + .gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Downloading); + group.set_completed_length(1536); + group.increment_retry_count(); + group.increment_retry_count(); + group.set_status(DownloadStatus::Active); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut session_file = load_session_file(&session_path) + .map_err(|_| CoreError::StorageUnavailable("failed to read session file"))?; + let entry = session_file + .entries + .first_mut() + .expect("saved session should contain one entry"); + let metadata = entry + .metadata + .as_mut() + .expect("saved session entry should contain metadata"); + metadata.remove("completed_length"); + metadata.remove("retry_count"); + save_session_file(&session_path, &session_file) + .map_err(|_| CoreError::StorageUnavailable("failed to write session file"))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + let snapshot = reader.progress_snapshot(loaded_gid)?; + + assert_eq!(loaded.status(), &DownloadStatus::Active); + assert_eq!(loaded.piece_state(PieceId(0)), Some(PieceState::Verified)); + assert_eq!( + loaded.piece_state(PieceId(1)), + Some(PieceState::Downloading) + ); + assert_eq!(loaded.completed_length(), 1536); + assert_eq!(loaded.retry_count(), 2); + assert_eq!(snapshot.total_length, 4096); + assert_eq!(snapshot.completed_length, 1536); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn schedule_once_sets_active_segments_and_runtime_bridge_state() { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("https://example.org/split.bin").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_total_length(8 * 1024); + group.set_completed_length(2 * 1024); + group.set_piece_length(1024); + group.set_status(DownloadStatus::Waiting); + + let decision = engine.schedule_once(); + assert_eq!(decision, ScheduleDecision::Queue(gid)); + + let group = engine.handle_mut(gid).expect("group should exist"); + assert_eq!(group.status(), &DownloadStatus::Active); + assert_eq!(group.num_connections(), 1); + assert_eq!(group.segment_assignments().len(), 1); + assert_eq!( + group + .segment_assignments() + .first() + .expect("one segment assignment should exist") + .range, + crate::piece::PieceRange::new(2 * 1024, 8 * 1024) + ); + + let bridge = engine.session().bridge(); + assert!(bridge.segment_plan.is_some()); + assert_eq!(bridge.completed_length, 2048); + assert_eq!(bridge.retry_count, 0); + assert_eq!(bridge.active_segments, 1); +} + +#[test] +fn schedule_once_propagates_error_as_retry_with_backpressure() { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("https://example.org/error.bin").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_total_length(4096); + group.set_completed_length(1024); + group.set_status(DownloadStatus::Error); + + let decision = engine.schedule_once(); + assert_eq!(decision, ScheduleDecision::RetryLater(gid)); + + let group = engine.handle_mut(gid).expect("group should exist"); + assert_eq!(group.status(), &DownloadStatus::Waiting); + assert_eq!(group.num_connections(), 0); + assert!(group.segment_assignments().is_empty()); + assert_eq!(group.retry_count(), 1); + assert_eq!(group.retry_attempts().len(), 1); + assert_eq!( + group + .retry_attempts() + .first() + .expect("one retry attempt should exist") + .error + .as_deref(), + Some("schedule-retry:error-state") + ); + + let bridge = engine.session().bridge(); + assert!(bridge.segment_plan.is_some()); + assert_eq!(bridge.retry_count, 1); + assert_eq!(bridge.retry_history.len(), 1); + assert_eq!(bridge.completed_length, 1024); + assert_eq!(bridge.active_segments, 0); +} + +#[test] +fn tell_stopped_orders_downloads_by_stop_sequence() { + let mut engine = DownloadEngine::new(); + let gid_a = engine.add_uri("https://example.org/a.bin").gid(); + let gid_b = engine.add_uri("https://example.org/b.bin").gid(); + let gid_c = engine.add_uri("https://example.org/c.bin").gid(); + let gid_d = engine.add_uri("https://example.org/d.bin").gid(); + + engine + .complete(gid_c) + .expect("complete transition should succeed"); + engine + .remove(gid_a) + .expect("remove transition should succeed"); + engine.fail(gid_d).expect("error transition should succeed"); + engine + .complete(gid_b) + .expect("complete transition should succeed"); + + let gids = engine + .tell_stopped() + .into_iter() + .map(super::DownloadHandle::gid) + .collect::>(); + assert_eq!(gids, vec![gid_c, gid_a, gid_d, gid_b]); +} + +#[test] +fn schedule_once_materializes_multiple_piece_aligned_segments() { + let mut engine = DownloadEngine::with_runtime(RuntimeConfig { + split: 4, + max_connections_per_server: 4, + max_connection_per_server: 4, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }); + let gid = engine + .add_uri("https://example.org/multi-segment.bin") + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_total_length(10 * 1024); + group.set_completed_length(2 * 1024); + group.set_piece_length(1024); + group.set_status(DownloadStatus::Waiting); + + let decision = engine.schedule_once(); + assert_eq!(decision, ScheduleDecision::Queue(gid)); + + let group = engine.handle_mut(gid).expect("group should exist"); + let assignments = group.segment_assignments(); + assert_eq!(assignments.len(), 4); + let first = assignments + .first() + .expect("first segment assignment should exist"); + let second = assignments + .get(1) + .expect("second segment assignment should exist"); + let third = assignments + .get(2) + .expect("third segment assignment should exist"); + let fourth = assignments + .get(3) + .expect("fourth segment assignment should exist"); + assert_eq!(first.range, crate::piece::PieceRange::new(2048, 4096)); + assert_eq!(second.range, crate::piece::PieceRange::new(4096, 6144)); + assert_eq!(third.range, crate::piece::PieceRange::new(6144, 8192)); + assert_eq!(fourth.range, crate::piece::PieceRange::new(8192, 10240)); + assert!( + assignments + .iter() + .all(|segment| segment.state == SegmentState::Active) + ); +} + +#[test] +fn schedule_once_starts_segments_from_resume_offset_when_ahead_of_completed_length() { + let mut engine = DownloadEngine::with_runtime(RuntimeConfig { + split: 3, + max_connections_per_server: 3, + max_connection_per_server: 3, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }); + let gid = engine + .add_uri("https://example.org/resume-segment.bin") + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_total_length(6 * 1024); + group.set_completed_length(1024); + group.set_piece_length(1024); + group.set_resume_state(ResumeState { + persisted: true, + resume_offset: 3 * 1024, + validated_length: Some(1024), + segment_cursor: Some(PieceId(3)), + }); + group.set_status(DownloadStatus::Waiting); + + let decision = engine.schedule_once(); + assert_eq!(decision, ScheduleDecision::Queue(gid)); + + let group = engine.handle_mut(gid).expect("group should exist"); + let assignments = group.segment_assignments(); + assert_eq!(assignments.len(), 3); + assert_eq!( + assignments + .first() + .expect("first resumed segment assignment should exist") + .range + .start, + 3 * 1024 + ); + assert_eq!( + assignments + .get(2) + .expect("third resumed segment assignment should exist") + .range + .end, + 6 * 1024 + ); +} + +#[test] +fn progress_snapshot_does_not_force_bt_complete_without_selected_payload() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("magnet:?xt=urn:btih:falsecomplete").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Complete); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_completed_length(0); + group.set_bt(BtRuntimeState { + info_hash: "falsecomplete".to_owned(), + metadata_only: true, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 4096, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(snapshot.total_length, 4096); + assert_eq!(snapshot.completed_length, 0); + assert!(!snapshot.seeding); + assert!(!snapshot.bt_true_seeding); + assert_eq!(snapshot.share_ratio_milli, None); + Ok(()) +} + +#[test] +fn bt_share_ratio_round_trip_and_pause_resume_state_survive_session_load() -> Result<()> { + let session_path = temp_session_path("bt-share-roundtrip.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut writer = DownloadEngine::with_runtime(runtime.clone()); + let gid = writer.add_uri("magnet:?xt=urn:btih:sharetest").gid(); + let group = writer + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Paused); + group.set_total_length(5000); + group.set_completed_length(3000); + group.set_upload_length(1500); + group.set_bt(BtRuntimeState { + info_hash: "sharetest".to_owned(), + metadata_only: false, + files: vec![ + BtFileInfo { + path: "a.bin".to_owned(), + length: 2000, + piece_offset: Some(0), + selected: true, + }, + BtFileInfo { + path: "b.bin".to_owned(), + length: 3000, + piece_offset: Some(2000), + selected: false, + }, + ], + ..BtRuntimeState::default() + }); + writer.save_session(SaveSessionTarget::Path(session_path.clone()))?; + + let mut reader = DownloadEngine::with_runtime(runtime); + reader.load_session(SaveSessionTarget::Path(session_path.clone()))?; + let loaded_gid = reader + .registry() + .handles() + .next() + .expect("loaded registry should contain one gid") + .gid(); + let loaded = reader + .registry() + .get(loaded_gid) + .expect("loaded group should exist"); + assert_eq!(loaded.status(), &DownloadStatus::Paused); + let snapshot = reader.progress_snapshot(loaded_gid)?; + assert_eq!(snapshot.share_ratio_milli, Some(500)); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); + Ok(()) +} + +#[test] +fn apply_bt_peer_snapshot_replaces_runtime_peer_view() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("magnet:?xt=urn:btih:peer-snapshot").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_num_connections(7); + group.set_bt(BtRuntimeState { + info_hash: "peer-snapshot".to_owned(), + metadata_only: false, + ..BtRuntimeState::default() + }); + + engine.apply_bt_peer_snapshot( + gid, + vec![ + 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: 111, + upload_speed: 222, + seeder: false, + }, + BtPeerInfo { + peer_id: Some("peer-b".to_owned()), + ip: "127.0.0.2".to_owned(), + port: 6882, + client_name: Some("client-b".to_owned()), + interested: false, + choked: true, + download_speed: 0, + upload_speed: 64, + seeder: true, + }, + ], + )?; + + let saved = engine + .registry() + .get(gid) + .and_then(RequestGroup::bt) + .expect("bt runtime state should exist"); + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(saved.peers.len(), 2); + assert_eq!( + saved + .peers + .first() + .expect("first saved peer should exist") + .ip, + "127.0.0.1" + ); + assert!( + saved + .peers + .get(1) + .expect("second saved peer should exist") + .seeder + ); + assert_eq!(snapshot.num_connections, 2); + Ok(()) +} + +#[test] +fn apply_bt_tracker_snapshot_updates_existing_tracker_and_can_append_new_one() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("magnet:?xt=urn:btih:tracker-snapshot").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_bt(BtRuntimeState { + info_hash: "tracker-snapshot".to_owned(), + metadata_only: false, + trackers: vec![BtTrackerInfo { + url: "http://tracker.example.org/announce".to_owned(), + tier: Some(0), + id: None, + seeders: None, + leechers: None, + }], + ..BtRuntimeState::default() + }); + + engine.apply_bt_tracker_snapshot( + gid, + "http://tracker.example.org/announce", + Some("session-a".to_owned()), + Some(12), + Some(4), + )?; + engine.apply_bt_tracker_snapshot( + gid, + "udp://tracker.example.org:6969/announce", + Some("session-b".to_owned()), + Some(18), + Some(6), + )?; + + let saved = engine + .registry() + .get(gid) + .and_then(RequestGroup::bt) + .expect("bt runtime state should exist"); + assert_eq!(saved.trackers.len(), 2); + let first_tracker = saved + .trackers + .first() + .expect("first saved tracker should exist"); + let second_tracker = saved + .trackers + .get(1) + .expect("second saved tracker should exist"); + assert_eq!(first_tracker.id.as_deref(), Some("session-a")); + assert_eq!(first_tracker.seeders, Some(12)); + assert_eq!(first_tracker.leechers, Some(4)); + assert_eq!( + second_tracker.url, + "udp://tracker.example.org:6969/announce" + ); + assert_eq!(second_tracker.id.as_deref(), Some("session-b")); + Ok(()) +} + +#[test] +fn record_bt_runtime_tick_updates_share_time_upload_and_completed_lengths() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("magnet:?xt=urn:btih:bt-runtime-tick").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Active); + group.set_total_length(4096); + group.set_completed_length(1024); + group.set_bt(BtRuntimeState { + info_hash: "bt-runtime-tick".to_owned(), + metadata_only: false, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 4096, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + + engine.record_bt_runtime_tick(gid, 2048, 1536, 30, 12, true)?; + let loaded = engine + .registry() + .get(gid) + .expect("group should still exist"); + assert_eq!(loaded.completed_length(), 3072); + assert_eq!(loaded.upload_length(), 1536); + assert_eq!(loaded.upload_speed(), 0); + assert!(loaded.bt_is_seeding()); + assert_eq!(loaded.bt_share_time_secs(), Some(30)); + assert_eq!(loaded.bt_seeding_time_secs(), Some(12)); + + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(snapshot.completed_length, 3072); + assert_eq!(snapshot.upload_length, 1536); + assert_eq!(snapshot.share_ratio_milli, Some(375)); + assert_eq!(snapshot.share_time_secs, Some(30)); + assert_eq!(snapshot.seeding_time_secs, Some(12)); + assert!(!snapshot.bt_true_seeding); + Ok(()) +} + +#[test] +fn bt_update_helpers_feed_progress_snapshot_runtime_metrics() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine + .add_uri("magnet:?xt=urn:btih:bt-progress-metrics") + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Active); + group.set_total_length(4 * 1024); + group.set_piece_length(1024); + group.set_bt(BtRuntimeState { + info_hash: "bt-progress-metrics".to_owned(), + metadata_only: false, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 4 * 1024, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + group.set_piece_state(PieceId(2), PieceState::Queued); + + let piece = engine.apply_bt_piece_block_update( + gid, + crate::request::BtPieceBlockUpdate { + piece_id: PieceId(0), + completed_blocks: 4, + total_blocks: 4, + }, + )?; + assert!(piece.transitioned_to_verified); + assert_eq!(piece.completed_length_delta, 1024); + + let availability = engine.apply_bt_piece_availability_update( + gid, + crate::request::BtPieceAvailabilityUpdate { + piece_id: PieceId(2), + peers_with_piece: 6, + }, + )?; + assert_eq!(availability.available_piece_count, 1); + + let peer = engine.apply_bt_peer_update( + gid, + 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: 700, + upload_speed: 350, + seeder: false, + }, + )?; + assert_eq!(peer.peer_count, 1); + assert_eq!(peer.total_download_speed, 700); + assert_eq!(peer.total_upload_speed, 350); + + engine.apply_bt_piece_block_update( + gid, + crate::request::BtPieceBlockUpdate { + piece_id: PieceId(1), + completed_blocks: 1, + total_blocks: 4, + }, + )?; + + let snapshot = engine.progress_snapshot(gid)?; + assert_eq!(snapshot.completed_length, 1024); + assert_eq!(snapshot.remaining_length(), 3 * 1024); + assert_eq!(snapshot.num_connections, 1); + assert_eq!(snapshot.download_speed, 700); + assert_eq!(snapshot.upload_speed, 350); + assert_eq!(snapshot.bt_total_peers, 1); + assert_eq!(snapshot.bt_seeders, 0); + assert_eq!(snapshot.bt_leechers, 1); + assert_eq!(snapshot.bt_available_pieces, 1); + assert_eq!(snapshot.bt_verified_pieces, 1); + assert_eq!(snapshot.bt_downloading_pieces, 1); + assert_eq!(snapshot.bt_queued_pieces, 1); + assert_eq!(snapshot.bt_missing_pieces, 0); + Ok(()) +} + +#[test] +fn bt_runtime_helpers_drive_true_seeding_and_share_runtime_snapshot() -> Result<()> { + let mut engine = DownloadEngine::new(); + let gid = engine.add_uri("magnet:?xt=urn:btih:bt-live-share").gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Active); + group.set_total_length(2_048); + group.set_completed_length(2_048); + group.set_bt(BtRuntimeState { + info_hash: "bt-live-share".to_owned(), + metadata_only: false, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 2_048, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + + let started = engine.set_bt_seeding_state(gid, true, Some(1_000))?; + assert!(started.seeding); + assert_eq!(started.share_ratio_milli, Some(0)); + + let advanced = engine.tick_bt_runtime_clock(gid, 1_040, true)?; + assert!(advanced.seeding); + assert_eq!(advanced.share_time_secs, 40); + assert_eq!(advanced.seeding_time_secs, 40); + + let tick = engine.apply_bt_runtime_tick(gid, 0, 1_024, 90, 180, 5, 5, true, Some(16))?; + assert!(tick.seeding); + assert_eq!(tick.upload_length, 1_024); + assert_eq!(tick.download_speed, 90); + assert_eq!(tick.upload_speed, 180); + assert_eq!(tick.num_connections, 16); + assert_eq!(tick.share_ratio_milli, Some(500)); + assert_eq!(tick.share_time_secs, 45); + assert_eq!(tick.seeding_time_secs, 45); + + let snapshot = engine.progress_snapshot(gid)?; + assert!(snapshot.seeding); + assert!(snapshot.bt_true_seeding); + assert_eq!(snapshot.share_ratio_milli, Some(500)); + assert_eq!(snapshot.share_time_secs, Some(45)); + assert_eq!(snapshot.seeding_time_secs, Some(45)); + assert_eq!(snapshot.bt_selected_payload_length, 2_048); + assert_eq!(snapshot.bt_remaining_payload_length, 0); + assert_eq!(snapshot.upload_speed, 180); + assert_eq!(snapshot.num_connections, 16); + Ok(()) +} + +#[test] +fn download_runtime_snapshot_exposes_segment_and_bt_pressure_metrics() -> Result<()> { + let mut engine = DownloadEngine::with_runtime(RuntimeConfig { + split: 4, + max_connections_per_server: 4, + max_connection_per_server: 4, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }); + let gid = engine + .add_uri("magnet:?xt=urn:btih:download-runtime-snapshot") + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly added group should exist"); + group.set_status(DownloadStatus::Active); + group.set_total_length(5 * 1024); + group.set_completed_length(1024); + group.set_piece_length(1024); + group.set_download_speed(900); + group.set_upload_speed(120); + group.set_bt(BtRuntimeState { + info_hash: "download-runtime-snapshot".to_owned(), + metadata_only: false, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 5 * 1024, + piece_offset: Some(0), + selected: true, + }], + peers: vec![BtPeerInfo { + peer_id: Some("peer-a".to_owned()), + ip: "203.0.113.10".to_owned(), + port: 6881, + client_name: None, + interested: true, + choked: false, + download_speed: 900, + upload_speed: 120, + seeder: false, + }], + ..BtRuntimeState::default() + }); + 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.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate { + piece_id: PieceId(1), + peers_with_piece: 1, + }); + + let decision = engine.schedule_once(); + assert_eq!(decision, ScheduleDecision::RunNow(gid)); + + let snapshot = engine.download_runtime_snapshot(gid)?; + assert_eq!(snapshot.gid, gid); + assert_eq!(snapshot.effective_download_limit, None); + assert_eq!(snapshot.effective_upload_limit, None); + assert_eq!(snapshot.segment_stats.segment_count, 4); + assert_eq!(snapshot.segment_stats.remaining_bytes, 4 * 1024); + assert_eq!( + snapshot + .bt_pressure + .as_ref() + .map(|pressure| pressure.requestable_pieces), + Some(2) + ); + assert_eq!( + snapshot + .bt_pressure + .as_ref() + .map(|pressure| pressure.scarce_requestable_pieces), + Some(1) + ); + Ok(()) +} + +#[test] +fn runtime_instrumentation_snapshot_aggregates_scheduler_and_resource_state() { + let mut engine = DownloadEngine::with_runtime(RuntimeConfig { + split: 3, + max_connections_per_server: 3, + max_connection_per_server: 3, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }); + let active_gid = engine.add_uri("https://example.org/a.bin").gid(); + let waiting_gid = engine.add_uri("magnet:?xt=urn:btih:runtime-global").gid(); + + let active = engine + .handle_mut(active_gid) + .expect("active group should exist"); + active.set_status(DownloadStatus::Active); + active.set_total_length(4 * 1024); + active.set_completed_length(1024); + active.set_piece_length(1024); + + let waiting = engine + .handle_mut(waiting_gid) + .expect("waiting group should exist"); + waiting.set_status(DownloadStatus::Waiting); + waiting.set_total_length(3 * 1024); + waiting.set_completed_length(0); + waiting.set_piece_length(1024); + waiting.set_bt(BtRuntimeState { + info_hash: "runtime-global".to_owned(), + metadata_only: false, + files: vec![BtFileInfo { + path: "payload.bin".to_owned(), + length: 3 * 1024, + piece_offset: Some(0), + selected: true, + }], + peers: vec![BtPeerInfo { + peer_id: Some("peer-a".to_owned()), + ip: "203.0.113.20".to_owned(), + port: 6881, + client_name: None, + interested: true, + choked: false, + download_speed: 100, + upload_speed: 20, + seeder: false, + }], + ..BtRuntimeState::default() + }); + waiting.set_piece_state(PieceId(0), PieceState::Pending); + waiting.set_piece_state(PieceId(1), PieceState::Downloading); + waiting.set_piece_state(PieceId(2), PieceState::Missing); + waiting.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate { + piece_id: PieceId(0), + peers_with_piece: 1, + }); + + let _ = engine.schedule_once(); + let runtime = engine.runtime_instrumentation_snapshot(); + + assert_eq!(runtime.download_count, 2); + assert_eq!(runtime.active_download_count, 1); + assert_eq!(runtime.waiting_download_count, 1); + assert_eq!(runtime.total_active_segments, 3); + assert_eq!(runtime.total_requestable_pieces, 2); + assert_eq!(runtime.total_scarce_requestable_pieces, 1); + assert_eq!(runtime.configured_disk_cache_bytes, 16 * 1024 * 1024); + assert_eq!(runtime.scheduler_counters.schedule_run_count, 1); + assert!(runtime.last_scheduler_plan.is_some()); +} + +#[test] +fn progress_and_global_stat_apply_speed_limits_from_runtime_and_group_options() -> Result<()> { + let mut engine = DownloadEngine::with_runtime(RuntimeConfig { + max_overall_download_limit: Some(1_200), + max_download_limit: Some(900), + max_overall_upload_limit: Some(600), + max_upload_limit: Some(500), + ..RuntimeConfig::default() + }); + + let gid_a = engine.add_uri("https://example.org/a.bin").gid(); + let gid_b = engine.add_uri("https://example.org/b.bin").gid(); + + let group_a = engine.handle_mut(gid_a).expect("group a should exist"); + group_a.set_status(DownloadStatus::Active); + group_a.set_download_speed(2_000); + group_a.set_upload_speed(900); + group_a.set_option("max-download-limit", "700"); + group_a.set_option("max-upload-limit", "200"); + + let group_b = engine.handle_mut(gid_b).expect("group b should exist"); + group_b.set_status(DownloadStatus::Active); + group_b.set_download_speed(2_000); + group_b.set_upload_speed(900); + + let snapshot_a = engine.progress_snapshot(gid_a)?; + let snapshot_b = engine.progress_snapshot(gid_b)?; + assert_eq!(snapshot_a.download_speed, 600); + assert_eq!(snapshot_a.upload_speed, 200); + assert_eq!(snapshot_b.download_speed, 600); + assert_eq!(snapshot_b.upload_speed, 300); + + let runtime_a = engine.download_runtime_snapshot(gid_a)?; + assert_eq!(runtime_a.effective_download_limit, Some(600)); + assert_eq!(runtime_a.effective_upload_limit, Some(200)); + assert_eq!(runtime_a.download_speed, 600); + assert_eq!(runtime_a.upload_speed, 200); + + let stat = engine.get_global_stat(); + assert_eq!(stat.download_speed, 1_200); + assert_eq!(stat.upload_speed, 500); + Ok(()) +} diff --git a/crates/aria2-rust-pro-core/src/error.rs b/crates/aria2-rust-pro-core/src/error.rs new file mode 100644 index 0000000..a372405 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/error.rs @@ -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 = std::result::Result; + +/// 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 {} diff --git a/crates/aria2-rust-pro-core/src/events.rs b/crates/aria2-rust-pro-core/src/events.rs new file mode 100644 index 0000000..7c47501 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/events.rs @@ -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, + /// Optional human-readable message text. + pub message: Option, + /// Optional progress snapshot captured for the event. + pub snapshot: Option, +} + +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) -> 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>, + /// FIFO queue of emitted events waiting to be drained. + queue: VecDeque, +} + +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 { + 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() + } +} diff --git a/crates/aria2-rust-pro-core/src/lib.rs b/crates/aria2-rust-pro-core/src/lib.rs new file mode 100644 index 0000000..86e8d54 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/lib.rs @@ -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; diff --git a/crates/aria2-rust-pro-core/src/options.rs b/crates/aria2-rust-pro-core/src/options.rs new file mode 100644 index 0000000..9c2f013 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/options.rs @@ -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) -> 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-keyed string map values. + Map(BTreeMap), + /// 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, +} + +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, + value: impl Into, + ) -> Option { + 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 { + &self.entries + } +} + +impl From<&str> for OptionKey { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for OptionKey { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From for OptionValue { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for OptionValue { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for OptionValue { + fn from(value: u64) -> Self { + Self::UInt(value) + } +} + +impl From 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> for OptionValue { + fn from(value: Vec) -> Self { + Self::List(value) + } +} diff --git a/crates/aria2-rust-pro-core/src/piece.rs b/crates/aria2-rust-pro-core/src/piece.rs new file mode 100644 index 0000000..3b141f6 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/piece.rs @@ -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, +} + +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 { + self.pieces.insert(id, state) + } + + /// Returns the state for a piece when present. + #[must_use] + pub fn get(&self, id: &PieceId) -> Option { + 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 { + 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() + } +} diff --git a/crates/aria2-rust-pro-core/src/progress.rs b/crates/aria2-rust-pro-core/src/progress.rs new file mode 100644 index 0000000..927626b --- /dev/null +++ b/crates/aria2-rust-pro-core/src/progress.rs @@ -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) -> 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, + /// Whether the runtime currently considers the download seeding. + pub seeding: bool, + /// Share ratio expressed in milli-units when available. + pub share_ratio_milli: Option, + /// Accumulated share time in seconds when available. + pub share_time_secs: Option, + /// Accumulated seeding time in seconds when available. + pub seeding_time_secs: Option, + /// 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); + } +} diff --git a/crates/aria2-rust-pro-core/src/request.rs b/crates/aria2-rust-pro-core/src/request.rs new file mode 100644 index 0000000..b9bcb34 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request.rs @@ -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; diff --git a/crates/aria2-rust-pro-core/src/request/bt.rs b/crates/aria2-rust-pro-core/src/request/bt.rs new file mode 100644 index 0000000..195a9d5 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/bt.rs @@ -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, + /// 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, + /// Optional tracker identifier reported by the server. + pub id: Option, + /// Seeder count reported by the tracker, when available. + pub seeders: Option, + /// Leecher count reported by the tracker, when available. + pub leechers: Option, +} + +/// 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, + /// Peer IP address or host. + pub ip: String, + /// Peer listening port. + pub port: u16, + /// Optional peer client identification string. + pub client_name: Option, + /// 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, + /// 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, + /// 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, + /// Original magnet URI when the torrent was bootstrapped from magnet. + pub magnet_uri: Option, + /// 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, + /// Known per-peer `ut_metadata` extension ids keyed by `host:port`. + pub metadata_extension_ids: BTreeMap, + /// Buffered metadata pieces keyed by metadata piece index. + pub metadata_piece_payloads: BTreeMap>, + /// Optional torrent creation date string. + pub creation_date: Option, + /// Optional torrent comment string. + pub comment: Option, + /// Known DHT bootstrap or discovered nodes. + pub dht_nodes: Vec, + /// Torrent file entries. + pub files: Vec, + /// Tracker entries associated with the torrent. + pub trackers: Vec, + /// Connected or recently seen peers. + pub peers: Vec, +} + +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 + '_ { + 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, + /// 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, + /// Wall-clock second of the last runtime tick update. + pub last_runtime_tick_secs: Option, +} + +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 { + self.share_ratio_milli + } + + /// Sets the current share ratio in thousandths. + pub fn set_share_ratio_milli(&mut self, value: Option) { + 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 { + 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); + } +} diff --git a/crates/aria2-rust-pro-core/src/request/context.rs b/crates/aria2-rust-pro-core/src/request/context.rs new file mode 100644 index 0000000..20186c2 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/context.rs @@ -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, + /// Primary URI shown on RPC and CLI surfaces. + pub uri: String, + /// Full ordered URI list associated with the request. + pub uris: Vec, + /// Optional HTTP referer applied to outbound requests. + pub referer: Option, + /// 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, + /// Optional diagnostic or migration note carried with the request. + pub note: Option, +} + +impl RequestContext { + /// Builds a request context seeded with one primary URI candidate. + #[must_use] + pub fn new(uri: impl Into) -> 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) { + 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) { + 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) { + 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, value: impl Into) { + self.headers.push((key.into(), value.into())); + } + + /// Normalizes an ordered URI list by dropping blank entries and duplicates. + fn normalize_uris(uris: Vec) -> Vec { + 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 { + (!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(); + } +} diff --git a/crates/aria2-rust-pro-core/src/request/group.rs b/crates/aria2-rust-pro-core/src/request/group.rs new file mode 100644 index 0000000..5f99ca5 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group.rs @@ -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), + } + } +} diff --git a/crates/aria2-rust-pro-core/src/request/group/bt_peers.rs b/crates/aria2-rust-pro-core/src/request/group/bt_peers.rs new file mode 100644 index 0000000..76fa3e0 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group/bt_peers.rs @@ -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) -> 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, + ) -> 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 { + self.bt.as_ref().map(BtRuntimeState::selected_file_count) + } + + #[must_use] + pub fn bt_selected_total_length(&self) -> Option { + 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) + } +} diff --git a/crates/aria2-rust-pro-core/src/request/group/bt_pieces.rs b/crates/aria2-rust-pro-core/src/request/group/bt_pieces.rs new file mode 100644 index 0000000..30f2b12 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group/bt_pieces.rs @@ -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 { + &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 { + 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 { + 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, + }) + } +} diff --git a/crates/aria2-rust-pro-core/src/request/group/bt_share.rs b/crates/aria2-rust-pro-core/src/request/group/bt_share.rs new file mode 100644 index 0000000..a39b3da --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group/bt_share.rs @@ -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 { + self.bt_share_state + .as_ref() + .and_then(BtShareRuntimeState::share_ratio_milli) + } + + #[must_use] + pub fn bt_share_time_secs(&self) -> Option { + self.bt_share_state + .as_ref() + .map(BtShareRuntimeState::share_time_secs) + } + + #[must_use] + pub fn bt_seeding_time_secs(&self) -> Option { + self.bt_share_state + .as_ref() + .map(BtShareRuntimeState::seeding_time_secs) + } + + #[must_use] + pub fn bt_share_ratio_base_length(&self) -> Option { + 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 { + 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 { + 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, + ) -> 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, + ) -> 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() + } +} diff --git a/crates/aria2-rust-pro-core/src/request/group/model.rs b/crates/aria2-rust-pro-core/src/request/group/model.rs new file mode 100644 index 0000000..4001e54 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group/model.rs @@ -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, + /// 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, + /// Resume metadata recovered from storage or prior runtime state. + pub(super) resume_state: Option, + /// DHT token cached for the next announce-peer exchange. + pub(super) dht_token: Option>, + /// Planned and active segment assignments for split transfers. + pub(super) segment_assignments: Vec, + /// Availability counters for each piece observed from swarm peers. + pub(super) piece_availability: BTreeMap, + /// BitTorrent-specific runtime state when the group is BT-backed. + pub(super) bt: Option, + /// Seeding and share-ratio counters when the BT runtime is active. + pub(super) bt_share_state: Option, +} diff --git a/crates/aria2-rust-pro-core/src/request/group/state.rs b/crates/aria2-rust-pro-core/src/request/group/state.rs new file mode 100644 index 0000000..39a11be --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/group/state.rs @@ -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) -> 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 { + 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, value: impl Into) { + self.options.insert(key, value); + } + + #[must_use] + pub fn option_limit(&self, key: &str) -> Option { + 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 { + self.stopped_sequence + } + + pub fn set_stopped_sequence(&mut self, value: Option) { + 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 { + &mut self.retry_attempts + } + + pub fn set_retry_attempts(&mut self, attempts: Vec) { + 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>) { + self.dht_token = token; + } + + #[must_use] + pub fn segment_assignments(&self) -> &[SegmentAssignment] { + &self.segment_assignments + } + + pub fn segment_assignments_mut(&mut self) -> &mut Vec { + &mut self.segment_assignments + } + + pub fn set_segment_assignments(&mut self, assignments: Vec) { + 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 = None; + let mut covered_end: Option = 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 { + 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, + } +} diff --git a/crates/aria2-rust-pro-core/src/request/identity.rs b/crates/aria2-rust-pro-core/src/request/identity.rs new file mode 100644 index 0000000..a1aa66d --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/identity.rs @@ -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 { + 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, + /// Human-readable error that triggered the retry. + pub error: Option, + /// 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, + /// Optional piece cursor used to continue segmented scheduling. + pub segment_cursor: Option, +} diff --git a/crates/aria2-rust-pro-core/src/request/request_tests.rs b/crates/aria2-rust-pro-core/src/request/request_tests.rs new file mode 100644 index 0000000..0d8ac77 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/request_tests.rs @@ -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); +} diff --git a/crates/aria2-rust-pro-core/src/request/segment.rs b/crates/aria2-rust-pro-core/src/request/segment.rs new file mode 100644 index 0000000..6dfe1f3 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/request/segment.rs @@ -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, +} diff --git a/crates/aria2-rust-pro-core/src/runtime.rs b/crates/aria2-rust-pro-core/src/runtime.rs new file mode 100644 index 0000000..5130af3 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/runtime.rs @@ -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, + /// Per-download download-rate cap in bytes per second. + pub max_download_limit: Option, + /// Global upload-rate cap in bytes per second. + pub max_overall_upload_limit: Option, + /// Per-download upload-rate cap in bytes per second. + pub max_upload_limit: Option, + /// 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, + /// 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) -> 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) -> 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 { + 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::().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); + } +} diff --git a/crates/aria2-rust-pro-core/src/scheduler.rs b/crates/aria2-rust-pro-core/src/scheduler.rs new file mode 100644 index 0000000..581081d --- /dev/null +++ b/crates/aria2-rust-pro-core/src/scheduler.rs @@ -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, +} + +/// 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, + /// 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) { + 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, +} + +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, + 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 { + 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 { + 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; diff --git a/crates/aria2-rust-pro-core/src/scheduler/scheduler_tests.rs b/crates/aria2-rust-pro-core/src/scheduler/scheduler_tests.rs new file mode 100644 index 0000000..ce18174 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/scheduler/scheduler_tests.rs @@ -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); +} diff --git a/crates/aria2-rust-pro-core/src/session.rs b/crates/aria2-rust-pro-core/src/session.rs new file mode 100644 index 0000000..f2db398 --- /dev/null +++ b/crates/aria2-rust-pro-core/src/session.rs @@ -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, +} + +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, value: impl Into) { + 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 { + &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, + /// In-memory snapshot slot used for round trips. + memory_snapshot: Option, + /// Path-keyed snapshots saved during the current process lifetime. + path_snapshots: BTreeMap, + /// 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, + /// 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, + /// 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, +} + +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) { + 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) { + 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, value: impl Into) { + 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, + ) { + 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 { + 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 { + 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); + } +} diff --git a/crates/aria2-rust-pro-protocol/Cargo.toml b/crates/aria2-rust-pro-protocol/Cargo.toml new file mode 100644 index 0000000..32480a8 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/Cargo.toml @@ -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 diff --git a/crates/aria2-rust-pro-protocol/src/auth.rs b/crates/aria2-rust-pro-protocol/src/auth.rs new file mode 100644 index 0000000..fcd2459 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/auth.rs @@ -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, + /// Optional password or shared secret. + pub password: Option, + /// Optional opaque bearer token. + pub token: Option, + /// Optional authentication realm. + pub realm: Option, +} + +/// 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, + /// Additional challenge parameters keyed by attribute name. + pub parameters: HashMap, +} + +/// 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, +} diff --git a/crates/aria2-rust-pro-protocol/src/bt.rs b/crates/aria2-rust-pro-protocol/src/bt.rs new file mode 100644 index 0000000..8725698 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/bt.rs @@ -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}, +}; diff --git a/crates/aria2-rust-pro-protocol/src/bt_metalink.rs b/crates/aria2-rust-pro-protocol/src/bt_metalink.rs new file mode 100644 index 0000000..3ec111b --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/bt_metalink.rs @@ -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, + /// Ordered tracker URLs from `tr` query fields. + pub trackers: Vec, + /// Ordered web-seed URLs from `ws` query fields. + pub web_seeds: Vec, +} + +/// 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, +} + +/// 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:` value. + pub fn parse(input: &str) -> Result { + 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 { + 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 { + 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#" + + wrapped fixture + + real parser payload + https://mirror.example.com/wrapped.iso + +"#, + ) + .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#" + + wrapper fixture + + release-42 + + AA BB + https://mirror.example.com/wrapped.iso + +"#, + ) + .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#" + + + + +"#, + ) + .expect_err("root-only stub success should be rejected"); + + assert!( + error + .to_string() + .contains("metalink document contains no resource urls"), + "unexpected error: {error}" + ); + } +} diff --git a/crates/aria2-rust-pro-protocol/src/downloader.rs b/crates/aria2-rust-pro-protocol/src/downloader.rs new file mode 100644 index 0000000..29d2478 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader.rs @@ -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 = 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> { + reqwest_connector::request_body_bytes(body) +} + +#[cfg(test)] +mod downloader_tests; diff --git a/crates/aria2-rust-pro-protocol/src/downloader/contracts.rs b/crates/aria2-rust-pro-protocol/src/downloader/contracts.rs new file mode 100644 index 0000000..bd37366 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader/contracts.rs @@ -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; +} + +/// Connects plain HTTP requests. +pub trait HttpConnector { + /// Executes one HTTP request and returns the normalized response model. + fn connect_http(&self, request: &HttpRequestModel) + -> Result; +} + +/// Connects HTTPS requests. +pub trait HttpsConnector { + /// Executes one HTTPS request and returns the normalized response model. + fn connect_https( + &self, + request: &HttpRequestModel, + ) -> Result; +} + +/// Connects FTP requests. +pub trait FtpConnector { + /// Executes one FTP request with the supplied session config. + fn connect_ftp( + &self, + config: &FtpConfigModel, + request: &FtpRequestModel, + ) -> Result; +} + +/// Connects SFTP requests. +pub trait SftpConnector { + /// Executes one SFTP request with the supplied session config. + fn connect_sftp( + &self, + config: &SftpConfigModel, + request: &SftpRequestModel, + ) -> Result; +} + +/// 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; +} + +/// 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; +} + +/// 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; + /// Starts one FTP transfer. + fn start_ftp_transfer( + &self, + config: &FtpConfigModel, + request: &FtpRequestModel, + ) -> Result; + /// Starts one SFTP transfer. + fn start_sftp_transfer( + &self, + config: &SftpConfigModel, + request: &SftpRequestModel, + ) -> Result; +} diff --git a/crates/aria2-rust-pro-protocol/src/downloader/core_downloader.rs b/crates/aria2-rust-pro-protocol/src/downloader/core_downloader.rs new file mode 100644 index 0000000..6606712 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader/core_downloader.rs @@ -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 { + 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 { + 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 { + /// Connector used for plain HTTP requests. + http: HC, + /// Connector used for HTTPS requests. + https: HSC, +} + +impl ConnectorBackedDownloader { + #[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, +} + +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 { + self.inner.start_http_transfer(task) + } + + fn start_ftp_transfer( + &self, + _config: &FtpConfigModel, + _request: &FtpRequestModel, + ) -> Result { + 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 { + 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 Downloader for ConnectorBackedDownloader +where + HC: HttpConnector, + HSC: HttpsConnector, +{ + fn start_http_transfer( + &self, + task: &HttpTransferTaskModel, + ) -> Result { + 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 { + 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 { + 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) +} diff --git a/crates/aria2-rust-pro-protocol/src/downloader/downloader_tests.rs b/crates/aria2-rust-pro-protocol/src/downloader/downloader_tests.rs new file mode 100644 index 0000000..826fb6c --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader/downloader_tests.rs @@ -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 { + 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 { + 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 { + 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::>(); + let unique = paths.iter().cloned().collect::>(); + 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"[..])); +} diff --git a/crates/aria2-rust-pro-protocol/src/downloader/fixture_downloader.rs b/crates/aria2-rust-pro-protocol/src/downloader/fixture_downloader.rs new file mode 100644 index 0000000..bfaada1 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader/fixture_downloader.rs @@ -0,0 +1,665 @@ +use super::{ + BTreeMap, ByteSink, ChecksumSpec, ContentRangeSpec, Downloader, FtpConfigModel, + FtpRequestModel, FtpResponseModel, HttpConnector, HttpHeader, HttpRequestModel, + HttpResponseHeaders, HttpResponseModel, HttpTransferTaskModel, HttpVersion, HttpsConnector, + Mutex, NEXT_TEMP_STREAM_SINK_ID, ObservedByteSink, ObservedFileSink, Ordering, RangeSpec, + RangeUnit, ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel, SystemTime, + TransportError, UNIX_EPOCH, + core_downloader::{normalize_http_response_for_execution, request_scheme}, + env, +}; + +#[derive(Debug, Default)] +/// Fixture-backed downloader used by tests and local runtime smokes. +pub struct FixtureHttpDownloader { + /// Inline HTTP and HTTPS fixtures keyed by URL. + fixtures: BTreeMap>, + /// FTP fixtures keyed by URL. + ftp_fixtures: BTreeMap, + /// SFTP fixtures keyed by URL. + sftp_fixtures: BTreeMap, + /// Scripted HTTP fixture responses keyed by URL. + scripts: Mutex>, +} + +impl FixtureHttpDownloader { + #[must_use] + /// Builds an empty fixture registry. + pub fn new() -> Self { + Self { + fixtures: BTreeMap::new(), + ftp_fixtures: BTreeMap::new(), + sftp_fixtures: BTreeMap::new(), + scripts: Mutex::new(BTreeMap::new()), + } + } + + /// Registers a simple inline HTTP fixture for `url`. + pub fn register(&mut self, url: impl Into, body: impl AsRef<[u8]>) { + self.fixtures.insert(url.into(), body.as_ref().to_vec()); + } + + /// Registers an FTP fixture response for `url`. + pub fn register_ftp(&mut self, url: impl Into, response: FtpResponseModel) { + self.ftp_fixtures.insert(url.into(), response); + } + + /// Registers an SFTP fixture response for `url`. + pub fn register_sftp(&mut self, url: impl Into, response: SftpResponseModel) { + self.sftp_fixtures.insert(url.into(), response); + } + + /// Registers a scripted sequence of responses for `url`. + pub fn register_script( + &self, + url: impl Into, + steps: impl IntoIterator, + ) { + let script = FixtureScript::new(steps); + if let Ok(mut scripts) = self.scripts.lock() { + scripts.insert(url.into(), script); + } + } + + /// Registers a single partial-content HTTP fixture for `url`. + pub fn register_partial_content( + &self, + url: impl Into, + body: impl AsRef<[u8]>, + start: u64, + end_inclusive: u64, + total: u64, + ) { + self.register_script( + url, + [FixtureStep::ok(HttpFixtureResponseSpec::partial_content( + body.as_ref().to_vec(), + start, + end_inclusive, + total, + ))], + ); + } + + /// Registers a successful inline HTTP fixture with explicit checksum metadata. + pub fn register_ok_with_checksum( + &self, + url: impl Into, + body: impl AsRef<[u8]>, + algorithm: impl Into, + expected_hex: impl Into, + actual_hex: Option>, + ) { + self.register_script( + url, + [FixtureStep::ok( + HttpFixtureResponseSpec::ok(body.as_ref().to_vec()).with_checksum(ChecksumSpec { + algorithm: algorithm.into(), + expected_hex: expected_hex.into(), + actual_hex: actual_hex.map(Into::into), + }), + )], + ); + } + + /// Registers a successful streamed HTTP fixture with explicit checksum metadata. + pub fn register_streamed_ok_with_checksum( + &self, + url: impl Into, + body: impl AsRef<[u8]>, + algorithm: impl Into, + expected_hex: impl Into, + ) { + self.register_script( + url, + [FixtureStep::ok( + HttpFixtureResponseSpec::streamed_ok(body.as_ref().to_vec()).with_checksum( + ChecksumSpec { + algorithm: algorithm.into(), + expected_hex: expected_hex.into(), + actual_hex: None, + }, + ), + )], + ); + } + + /// Registers a transient failure followed by a successful inline response. + pub fn register_transient_failure_then_ok( + &self, + url: impl Into, + kind: crate::transport::TransportErrorKind, + message: impl Into, + body: impl AsRef<[u8]>, + ) { + self.register_script( + url, + [ + FixtureStep::err(kind, message), + FixtureStep::ok(HttpFixtureResponseSpec::ok(body.as_ref().to_vec())), + ], + ); + } + + /// Resolves a registered fixture body or scripted response for one HTTP transfer task. + fn fixture_response( + &self, + task: &HttpTransferTaskModel, + ) -> Result { + let url = &task.request.url; + if let Ok(mut scripts) = self.scripts.lock() + && let Some(script) = scripts.get_mut(url) + { + return script.next_response(); + } + + let Some(body) = self.fixtures.get(url) else { + return Err(TransportError { + kind: crate::transport::TransportErrorKind::NotConnected, + message: format!("no fixture registered for {url}"), + source: None, + context: None, + }); + }; + + if let Some(range) = task.request.range.as_ref() { + return response_for_range(url, body, range); + } + + Ok(HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: vec![HttpHeader { + name: "content-length".to_owned(), + value: body.len().to_string(), + kind: crate::http::HeaderKind::Response, + }], + }, + body: ResponseBody::Inline(body.clone()), + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }) + } +} + +/// Slices fixture bytes according to an optional HTTP range request. +fn response_for_range( + url: &str, + body: &[u8], + range: &RangeSpec, +) -> Result { + if !matches!(range.unit, RangeUnit::Bytes) { + return Err(TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!( + "fixture range unit not supported for {url}: {:?}", + range.unit + ), + source: None, + context: None, + }); + } + + if body.is_empty() { + return Ok(HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: vec![HttpHeader { + name: "content-length".to_owned(), + value: "0".to_owned(), + kind: crate::http::HeaderKind::Response, + }], + }, + body: ResponseBody::Empty, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }); + } + + let start = usize::try_from(range.start).map_err(|_| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!( + "fixture range starts past addressable memory for {url}: {}", + range.start + ), + source: None, + context: None, + })?; + if start >= body.len() { + return Err(TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("fixture range starts past body for {url}: {start}"), + source: None, + context: None, + }); + } + + let last_index = body.len().saturating_sub(1); + let end_inclusive = range + .end_inclusive + .and_then(|end| usize::try_from(end).ok()) + .unwrap_or(last_index) + .min(last_index); + let end_inclusive = end_inclusive.max(start); + let slice = body + .get(start..=end_inclusive) + .ok_or_else(|| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("fixture range slice is invalid for {url}: {start}..={end_inclusive}"), + source: None, + context: None, + })? + .to_vec(); + let total = u64::try_from(body.len()).unwrap_or(u64::MAX); + + Ok(HttpResponseModel { + status: 206, + reason: "Partial Content".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: vec![ + HttpHeader { + name: "content-length".to_owned(), + value: slice.len().to_string(), + kind: crate::http::HeaderKind::Response, + }, + HttpHeader { + name: "content-range".to_owned(), + value: format!("bytes {start}-{end_inclusive}/{total}"), + kind: crate::http::HeaderKind::Response, + }, + ], + }, + body: ResponseBody::Inline(slice), + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: u64::try_from(start).unwrap_or(u64::MAX), + end_inclusive: u64::try_from(end_inclusive).unwrap_or(u64::MAX), + total_size: Some(total), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }) +} + +#[derive(Clone, Debug)] +/// Ordered script that can emit fixture responses across repeated attempts. +struct FixtureScript { + /// Ordered scripted fixture steps. + steps: Vec, + /// Cursor pointing at the next step to emit. + cursor: usize, +} + +impl FixtureScript { + /// Builds a fixture script from an ordered step sequence. + fn new(steps: impl IntoIterator) -> Self { + Self { + steps: steps.into_iter().collect(), + cursor: 0, + } + } + + /// Returns the next scripted response, pinning to the final step once exhausted. + fn next_response(&mut self) -> Result { + if self.steps.is_empty() { + return Err(TransportError { + kind: crate::transport::TransportErrorKind::NotConnected, + message: "fixture script is empty".to_owned(), + source: None, + context: None, + }); + } + + let idx = self.cursor.min(self.steps.len() - 1); + if self.cursor < self.steps.len() - 1 { + self.cursor += 1; + } + + self.steps[idx].to_result() + } +} + +#[derive(Clone, Debug)] +/// One scripted fixture step for the HTTP fixture downloader. +pub enum FixtureStep { + /// Emits a successful HTTP response described by the fixture spec. + Response(HttpFixtureResponseSpec), + /// Emits a transport error with the provided kind and message. + Error { + /// Error kind surfaced by the scripted step. + kind: crate::transport::TransportErrorKind, + /// Human-readable error message surfaced by the scripted step. + message: String, + }, +} + +impl FixtureStep { + #[must_use] + /// Builds a successful fixture step from a response spec. + pub const fn ok(spec: HttpFixtureResponseSpec) -> Self { + Self::Response(spec) + } + + #[must_use] + /// Builds an error fixture step from a transport error kind and message. + pub fn err(kind: crate::transport::TransportErrorKind, message: impl Into) -> Self { + Self::Error { + kind, + message: message.into(), + } + } + + /// Converts one scripted step into the response or error it represents. + fn to_result(&self) -> Result { + match self { + Self::Response(spec) => Ok(spec.to_http_response()), + Self::Error { kind, message } => Err(TransportError { + kind: *kind, + message: message.clone(), + source: None, + context: None, + }), + } + } +} + +#[derive(Clone, Debug)] +/// Declarative HTTP response fixture used by `FixtureHttpDownloader`. +pub struct HttpFixtureResponseSpec { + /// HTTP status code emitted by the fixture. + pub(super) status: u16, + /// HTTP reason phrase emitted by the fixture. + pub(super) reason: String, + /// Response headers emitted by the fixture. + pub(super) headers: Vec, + /// Inline payload bytes used by the fixture. + pub(super) body: Vec, + /// Optional checksum metadata attached to the fixture response. + pub(super) checksum: Option, + /// Whether the fixture should materialize a streamed response body. + pub(super) streamed: bool, +} + +impl HttpFixtureResponseSpec { + #[must_use] + /// Builds a successful inline-body HTTP fixture. + pub fn ok(body: Vec) -> Self { + Self { + status: 200, + reason: "OK".to_owned(), + headers: vec![HttpHeader { + name: "content-length".to_owned(), + value: body.len().to_string(), + kind: crate::http::HeaderKind::Response, + }], + body, + checksum: None, + streamed: false, + } + } + + #[must_use] + /// Builds a successful streamed-body HTTP fixture. + pub fn streamed_ok(body: Vec) -> Self { + Self { + status: 200, + reason: "OK".to_owned(), + headers: vec![HttpHeader { + name: "content-length".to_owned(), + value: body.len().to_string(), + kind: crate::http::HeaderKind::Response, + }], + body, + checksum: None, + streamed: true, + } + } + + #[must_use] + /// Builds a `206 Partial Content` HTTP fixture. + pub fn partial_content(body: Vec, start: u64, end_inclusive: u64, total: u64) -> Self { + Self { + status: 206, + reason: "Partial Content".to_owned(), + headers: vec![ + HttpHeader { + name: "content-length".to_owned(), + value: body.len().to_string(), + kind: crate::http::HeaderKind::Response, + }, + HttpHeader { + name: "content-range".to_owned(), + value: format!("bytes {start}-{end_inclusive}/{total}"), + kind: crate::http::HeaderKind::Response, + }, + ], + body, + checksum: None, + streamed: false, + } + } + + #[must_use] + /// Attaches checksum metadata to the fixture response. + pub fn with_checksum(mut self, checksum: ChecksumSpec) -> Self { + self.checksum = Some(checksum); + self + } + + /// Converts the declarative fixture into a concrete HTTP response model. + fn to_http_response(&self) -> HttpResponseModel { + let body = if self.streamed { + execute_streamed_body(&self.body, self.checksum.as_ref()) + } else { + ResponseBody::Inline(self.body.clone()) + }; + HttpResponseModel { + status: self.status, + reason: self.reason.clone(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: self.headers.clone(), + }, + body, + content_range: parse_content_range(&self.headers), + partial_content: self.status == 206, + checksum: self.checksum.clone(), + redirected_from: None, + } + } +} + +/// Materializes fixture bytes into an observed streamed body representation. +pub(super) fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody { + let temp_path = temp_stream_sink_path(); + let streamed = ObservedFileSink::create(&temp_path).map_or_else( + |_| { + let mut sink = ObservedByteSink::with_unbounded_retention(); + ByteSink::write(&mut sink, body).expect("observed sink write is infallible"); + let observed_len = sink.observed_len(); + let observed_digest = + checksum.and_then(|spec| spec.compute_actual_hex(sink.retained())); + (observed_len, observed_digest, None) + }, + |mut sink| { + ByteSink::write(&mut sink, body).expect("observed file sink write is infallible"); + let observed_len = sink.observed_len(); + let observed_digest = + checksum.and_then(|spec| spec.compute_actual_hex(sink.retained())); + (observed_len, observed_digest, Some(temp_path)) + }, + ); + + ResponseBody::Streamed { + expected_len: Some(u64::try_from(body.len()).unwrap_or(u64::MAX)), + observed_len: Some(streamed.0), + observed_digest: streamed.1, + temp_path: streamed.2, + } +} + +/// Allocates a best-effort temporary file path for streamed fixture bodies. +pub(super) fn temp_stream_sink_path() -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let sequence = NEXT_TEMP_STREAM_SINK_ID.fetch_add(1, Ordering::Relaxed); + env::temp_dir().join(format!( + "aria2-rust-pro-streamed-{}-{}-{}.bin", + std::process::id(), + stamp, + sequence + )) +} + +/// Parses a `Content-Range` response header into the protocol-layer range model. +pub(super) fn parse_content_range(headers: &[HttpHeader]) -> Option { + let value = headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("content-range"))? + .value + .trim(); + let rest = value.strip_prefix("bytes ")?; + let (range_part, total_part) = rest.split_once('/')?; + let total_size = if total_part == "*" { + None + } else { + Some(total_part.parse::().ok()?) + }; + if range_part == "*" { + return Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 0, + end_inclusive: 0, + total_size, + unsatisfied: true, + }); + } + let (start, end) = range_part.split_once('-')?; + let start = start.parse::().ok()?; + let end_inclusive = end.parse::().ok()?; + Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start, + end_inclusive, + total_size, + unsatisfied: false, + }) +} + +impl HttpConnector for FixtureHttpDownloader { + fn connect_http( + &self, + request: &HttpRequestModel, + ) -> Result { + let retry = request.retry; + self.fixture_response(&HttpTransferTaskModel { + task_id: String::new(), + request: request.clone(), + response_headers: HttpResponseHeaders { + headers: Vec::new(), + }, + body: ResponseBody::Empty, + resume_state: None, + retry_attempts: Vec::new(), + checksum_hook: None, + max_connections: 1, + retry, + }) + } +} + +impl HttpsConnector for FixtureHttpDownloader { + fn connect_https( + &self, + request: &HttpRequestModel, + ) -> Result { + let retry = request.retry; + self.fixture_response(&HttpTransferTaskModel { + task_id: String::new(), + request: request.clone(), + response_headers: HttpResponseHeaders { + headers: Vec::new(), + }, + body: ResponseBody::Empty, + resume_state: None, + retry_attempts: Vec::new(), + checksum_hook: None, + max_connections: 1, + retry, + }) + } +} + +impl Downloader for FixtureHttpDownloader { + fn start_http_transfer( + &self, + task: &HttpTransferTaskModel, + ) -> Result { + match request_scheme(&task.request.url) { + Some("http" | "https") => self + .fixture_response(task) + .map(|response| normalize_http_response_for_execution(task, response)), + Some(scheme) => Err(TransportError { + kind: crate::transport::TransportErrorKind::UnsupportedScheme, + message: format!("fixture downloader does not support 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 { + let path = request.path.as_deref().unwrap_or_default(); + let url = format!("ftp://{}:{}{}", config.host, config.port, path); + self.ftp_fixtures + .get(&url) + .cloned() + .ok_or_else(|| TransportError { + kind: crate::transport::TransportErrorKind::UnsupportedScheme, + message: format!("ftp fixture not registered for {url}"), + source: None, + context: None, + }) + } + + fn start_sftp_transfer( + &self, + config: &SftpConfigModel, + request: &SftpRequestModel, + ) -> Result { + let path = request.path.as_deref().unwrap_or_default(); + let url = format!("sftp://{}:{}{}", config.host, config.port, path); + self.sftp_fixtures + .get(&url) + .cloned() + .ok_or_else(|| TransportError { + kind: crate::transport::TransportErrorKind::UnsupportedScheme, + message: format!("sftp fixture not registered for {url}"), + source: None, + context: None, + }) + } +} diff --git a/crates/aria2-rust-pro-protocol/src/downloader/reqwest_connector.rs b/crates/aria2-rust-pro-protocol/src/downloader/reqwest_connector.rs new file mode 100644 index 0000000..49a67f1 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/downloader/reqwest_connector.rs @@ -0,0 +1,641 @@ +use super::{ + Arc, Client, File, HTTP_TIMING_PROBE_ENABLED, HashMap, HeaderMap, HeaderName, HeaderValue, + HttpConnector, HttpHeader, HttpRequestModel, HttpResponseHeaders, HttpResponseModel, + HttpVersion, HttpsConnector, LIVE_HTTP_POOL_IDLE_TIMEOUT, LIVE_HTTP_POOL_MAX_IDLE_PER_HOST, + LIVE_HTTP_TCP_KEEPALIVE, MAX_PREPARED_REQUEST_CACHE_ENTRIES, MAX_PROXY_CLIENT_CACHE_ENTRIES, + Mutex, NoProxy, OpenOptions, Proxy, RANGE, Response, ResponseBody, SeekFrom, StdError, + TransportError, env, + fixture_downloader::{parse_content_range, temp_stream_sink_path}, +}; + +use std::io::Seek as _; + +#[derive(Clone, Debug)] +/// Live reqwest-backed connector for HTTP and HTTPS requests. +pub struct ReqwestHttpConnector { + /// Reused client for the common no-proxy request path. + default_client: Client, + /// Reused clients for proxy-specific request paths. + proxy_clients: Arc>>, + /// Reused reqwest URL/header preparation keyed by immutable request shape. + pub(super) prepared_requests: + Arc>>>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Stable cache key for proxy-specific reqwest clients. +struct ProxyClientCacheKey { + /// Proxy URL scheme. + scheme: String, + /// Proxy host name or address. + host: String, + /// Proxy TCP port. + port: u16, + /// Optional proxy username. + username: Option, + /// Optional proxy password. + password: Option, + /// Hosts bypassed by this proxy. + bypass_hosts: Vec, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Stable cache key for normalized live HTTP request preparation. +pub(super) struct PreparedLiveHttpRequestKey { + /// Base request URL before query map application. + url: String, + /// Stable sorted query parameters. + query: Vec<(String, String)>, + /// Stable sorted request headers. + headers: Vec<(String, String)>, +} + +#[derive(Clone, Debug)] +/// Prepared reqwest request pieces reusable across repeated equivalent requests. +pub(super) struct PreparedLiveHttpRequest { + /// Parsed request URL with stable query parameters applied. + requested_url: reqwest::Url, + /// Validated reqwest header map. + headers: HeaderMap, +} + +impl ReqwestHttpConnector { + /// Builds a connector after validating that a reqwest client can be created. + /// + /// # Errors + /// + /// Returns an error when the underlying reqwest client cannot be built. + pub fn new() -> Result { + let default_client = build_reqwest_client(None)?; + Ok(Self { + default_client, + proxy_clients: Arc::new(Mutex::new(HashMap::new())), + prepared_requests: Arc::new(Mutex::new(HashMap::new())), + }) + } + + /// Selects either the shared default client or a proxy-specific client. + pub(super) fn client_for_request( + &self, + request: &HttpRequestModel, + ) -> Result { + if let Some(proxy) = request.proxy.as_ref().filter(|proxy| !proxy.no_proxy) { + return self.proxy_client_for(proxy); + } + Ok(self.default_client.clone()) + } + + /// Returns a cached or newly built client for one proxy configuration. + fn proxy_client_for(&self, proxy: &crate::http::ProxyConfig) -> Result { + let key = ProxyClientCacheKey::from_config(proxy); + + if let Some(client) = self + .proxy_clients + .lock() + .ok() + .and_then(|proxy_clients| proxy_clients.get(&key).cloned()) + { + return Ok(client); + } + + let client = build_reqwest_client(Some(proxy))?; + if let Ok(mut proxy_clients) = self.proxy_clients.lock() { + if proxy_clients.len() >= MAX_PROXY_CLIENT_CACHE_ENTRIES { + if let Some(cached) = proxy_clients.get(&key) { + return Ok(cached.clone()); + } + proxy_clients.clear(); + } + let cached = proxy_clients.entry(key).or_insert_with(|| client.clone()); + return Ok(cached.clone()); + } + Ok(client) + } + + #[cfg(test)] + /// Returns the number of cached proxy-specific clients for cache tests. + pub(super) fn cached_proxy_client_count(&self) -> usize { + self.proxy_clients + .lock() + .map(|proxy_clients| proxy_clients.len()) + .unwrap_or_default() + } + + /// Returns cached parsed URL/header state for repeated equivalent requests. + pub(super) fn prepared_live_request_for( + &self, + request: &HttpRequestModel, + ) -> Option> { + let key = match self.prepared_requests.try_lock() { + Ok(prepared_requests) => { + let key = PreparedLiveHttpRequestKey::from_request(request); + if let Some(prepared) = prepared_requests.get(&key).cloned() { + return Some(prepared); + } + key + } + Err(_) => { + return PreparedLiveHttpRequest::from_request(request).map(Arc::new); + } + }; + + let prepared = Arc::new(PreparedLiveHttpRequest::from_request(request)?); + if let Ok(mut prepared_requests) = self.prepared_requests.try_lock() { + if prepared_requests.len() >= MAX_PREPARED_REQUEST_CACHE_ENTRIES { + if let Some(cached) = prepared_requests.get(&key) { + return Some(cached.clone()); + } + prepared_requests.clear(); + } + if let Some(cached) = prepared_requests.get(&key) { + return Some(cached.clone()); + } + prepared_requests.insert(key, prepared.clone()); + } + Some(prepared) + } +} + +impl ProxyClientCacheKey { + /// Builds a stable key from protocol-layer proxy configuration. + fn from_config(proxy: &crate::http::ProxyConfig) -> Self { + Self { + scheme: proxy.scheme.clone(), + host: proxy.host.clone(), + port: proxy.port, + username: proxy.username.clone(), + password: proxy.password.clone(), + bypass_hosts: proxy.bypass_hosts.clone(), + } + } +} + +impl PreparedLiveHttpRequestKey { + /// Builds a stable key from immutable request URL/query/header data. + fn from_request(request: &HttpRequestModel) -> Self { + let mut query = request + .query + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + if query.len() > 1 { + query.sort_unstable(); + } + + let mut headers = request + .headers + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect::>(); + if headers.len() > 1 { + headers.sort_unstable(); + } + + Self { + url: request.url.clone(), + query, + headers, + } + } +} + +impl PreparedLiveHttpRequest { + /// Parses and validates reusable URL/header state from one request. + fn from_request(request: &HttpRequestModel) -> Option { + let requested_url = request_url_with_query(request).ok()?; + let headers = http_headers_from_request(&request.headers.headers).ok()?; + Some(Self { + requested_url, + headers, + }) + } +} + +impl Default for ReqwestHttpConnector { + fn default() -> Self { + Self::new().expect("reqwest client should build") + } +} + +impl HttpConnector for ReqwestHttpConnector { + fn connect_http( + &self, + request: &HttpRequestModel, + ) -> Result { + let prepared = self.prepared_live_request_for(request); + live_http_response( + self.client_for_request(request)?, + request, + prepared.as_deref(), + ) + } +} + +impl HttpsConnector for ReqwestHttpConnector { + fn connect_https( + &self, + request: &HttpRequestModel, + ) -> Result { + let prepared = self.prepared_live_request_for(request); + live_http_response( + self.client_for_request(request)?, + request, + prepared.as_deref(), + ) + } +} + +/// Builds one reqwest client with the protocol-layer defaults and optional proxy. +/// +/// # Errors +/// +/// Returns an error when reqwest rejects the configured client or proxy settings. +fn build_reqwest_client( + proxy: Option<&crate::http::ProxyConfig>, +) -> Result { + let mut builder = Client::builder() + .no_proxy() + .tcp_nodelay(true) + .tcp_keepalive(LIVE_HTTP_TCP_KEEPALIVE) + .pool_max_idle_per_host(LIVE_HTTP_POOL_MAX_IDLE_PER_HOST) + .pool_idle_timeout(LIVE_HTTP_POOL_IDLE_TIMEOUT) + .redirect(reqwest::redirect::Policy::limited(10)); + + if let Some(proxy) = proxy.filter(|proxy| !proxy.no_proxy) { + builder = builder.proxy(reqwest_proxy_from_config(proxy)?); + } + + builder.build().map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("failed to build reqwest client: {error}"), + source: Some(error.to_string()), + context: None, + }) +} + +/// Converts the protocol-layer proxy model into a reqwest proxy configuration. +fn reqwest_proxy_from_config(proxy: &crate::http::ProxyConfig) -> Result { + let proxy_url = format!("{}://{}:{}", proxy.scheme, proxy.host, proxy.port); + let mut reqwest_proxy = match proxy.scheme.as_str() { + "http" => Proxy::http(&proxy_url).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("invalid http proxy config for {proxy_url}: {error}"), + source: Some(error.to_string()), + context: None, + })?, + "https" => Proxy::https(&proxy_url).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("invalid https proxy config for {proxy_url}: {error}"), + source: Some(error.to_string()), + context: None, + })?, + other => { + return Err(TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("unsupported proxy scheme: {other}"), + source: None, + context: None, + }); + } + }; + + if !proxy.bypass_hosts.is_empty() { + reqwest_proxy = reqwest_proxy.no_proxy(NoProxy::from_string(&proxy.bypass_hosts.join(","))); + } + + if let Some(username) = proxy.username.as_deref() { + reqwest_proxy = + reqwest_proxy.basic_auth(username, proxy.password.as_deref().unwrap_or_default()); + } + + Ok(reqwest_proxy) +} + +/// Executes one live HTTP request through reqwest and normalizes the response model. +fn live_http_response( + client: Client, + request: &HttpRequestModel, + prepared_live_request: Option<&PreparedLiveHttpRequest>, +) -> Result { + let timing_probe = *HTTP_TIMING_PROBE_ENABLED + .get_or_init(|| env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some()); + let overall_started = timing_probe.then(std::time::Instant::now); + let client_started = timing_probe.then(std::time::Instant::now); + let client_elapsed_ms = client_started + .as_ref() + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default(); + let requested_url = if let Some(prepared) = prepared_live_request { + prepared.requested_url.clone() + } else { + request_url_with_query(request)? + }; + + let method = match request.method { + crate::http::HttpMethod::Get => reqwest::Method::GET, + crate::http::HttpMethod::Head => reqwest::Method::HEAD, + crate::http::HttpMethod::Post => reqwest::Method::POST, + crate::http::HttpMethod::Put => reqwest::Method::PUT, + crate::http::HttpMethod::Delete => reqwest::Method::DELETE, + }; + + let mut builder = client.request(method, requested_url.clone()); + builder = builder.headers(if let Some(prepared) = prepared_live_request { + prepared.headers.clone() + } else { + http_headers_from_request(&request.headers.headers)? + }); + if let Some(body) = request_body_bytes(&request.body) { + builder = builder.body(body); + } + + if let Some(range) = &request.range { + let range_value = range.end_inclusive.map_or_else( + || format!("bytes={}-", range.start), + |end| format!("bytes={}-{}", range.start, end), + ); + builder = builder.header(RANGE, range_value); + } + + if let Some(auth) = &request.auth + && let Some(username) = auth.username.as_deref() + { + builder = builder.basic_auth(username, auth.password.as_deref()); + } + + let send_started = timing_probe.then(std::time::Instant::now); + let response = builder + .send() + .map_err(|error| transport_error_from_reqwest(error, request))?; + let send_elapsed_ms = send_started + .as_ref() + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default(); + let normalize_started = timing_probe.then(std::time::Instant::now); + let response = http_response_from_reqwest(response, request, &requested_url)?; + if let Some(total_started) = overall_started.as_ref() { + eprintln!( + "http connector timing url={} client_ms={} send_ms={} normalize_ms={} total_ms={}", + request.url, + client_elapsed_ms, + send_elapsed_ms, + normalize_started + .as_ref() + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default(), + total_started.elapsed().as_millis(), + ); + } + Ok(response) +} + +/// Rebuilds the request URL with its query map in stable key order. +fn request_url_with_query(request: &HttpRequestModel) -> Result { + let mut url = reqwest::Url::parse(&request.url).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("invalid request url {}: {error}", request.url), + source: Some(error.to_string()), + context: None, + })?; + + if !request.query.is_empty() { + let mut query_pairs = request + .query + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect::>(); + query_pairs.sort_unstable(); + + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query_pairs { + pairs.append_pair(key, value); + } + } + } + + Ok(url) +} + +/// Converts protocol-layer request headers into a reqwest header map. +fn http_headers_from_request(headers: &[HttpHeader]) -> Result { + let mut map = HeaderMap::with_capacity(headers.len()); + for header in headers { + let name = + HeaderName::from_bytes(header.name.as_bytes()).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("invalid request header name {}: {error}", header.name), + source: Some(error.to_string()), + context: None, + })?; + let value = HeaderValue::from_str(&header.value).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::ProtocolViolation, + message: format!("invalid request header value for {}: {error}", header.name), + source: Some(error.to_string()), + context: None, + })?; + map.append(name, value); + } + Ok(map) +} + +/// Extracts an owned request payload when the body model is inline or textual. +pub(super) fn request_body_bytes(body: &crate::http::HttpBody) -> Option> { + match body { + crate::http::HttpBody::Empty | crate::http::HttpBody::Stream { .. } => None, + crate::http::HttpBody::Text(text) => Some(text.clone().into_bytes()), + crate::http::HttpBody::Binary(bytes) => Some(bytes.clone()), + } +} + +/// Maps a reqwest failure into the transport error model with request context. +fn transport_error_from_reqwest( + error: reqwest::Error, + request: &HttpRequestModel, +) -> TransportError { + let kind = transport_error_kind_from_reqwest(&error, request.proxy.as_ref()); + TransportError { + kind, + message: format!("http request failed for {}: {error}", request.url), + source: Some(error.to_string()), + context: None, + } +} + +/// Classifies a reqwest failure into the closest transport error kind. +fn transport_error_kind_from_reqwest( + error: &reqwest::Error, + proxy: Option<&crate::http::ProxyConfig>, +) -> crate::transport::TransportErrorKind { + let chain_text = reqwest_error_chain_text(error); + let is_proxy_configured = proxy.is_some_and(|proxy| !proxy.no_proxy); + + if error.is_timeout() { + crate::transport::TransportErrorKind::Timeout + } else if contains_any( + &chain_text, + &[ + "tls", + "certificate", + "handshake", + "unknown ca", + "invalid peer", + ], + ) { + crate::transport::TransportErrorKind::TlsFailed + } else if contains_any( + &chain_text, + &[ + "dns", + "resolve", + "lookup address", + "name or service not known", + "no such host", + ], + ) { + crate::transport::TransportErrorKind::DnsFailed + } else if is_proxy_configured + && (error.is_connect() || contains_any(&chain_text, &["proxy", "tunnel", "socks"])) + { + crate::transport::TransportErrorKind::ProxyFailed + } else if error.is_connect() { + crate::transport::TransportErrorKind::ConnectionReset + } else if error.is_builder() { + crate::transport::TransportErrorKind::ProtocolViolation + } else { + crate::transport::TransportErrorKind::Io + } +} + +/// Flattens one reqwest/std-error chain into a lowercased diagnostic string. +fn reqwest_error_chain_text(error: &dyn StdError) -> String { + let mut text = error.to_string().to_lowercase(); + let mut source = error.source(); + while let Some(err) = source { + text.push_str(" | "); + text.push_str(&err.to_string().to_lowercase()); + source = err.source(); + } + text +} + +/// Returns whether the haystack contains any candidate substring. +fn contains_any(text: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| text.contains(needle)) +} + +/// Normalizes a reqwest response into the protocol-layer HTTP response model. +fn http_response_from_reqwest( + mut response: Response, + request: &HttpRequestModel, + requested_url: &reqwest::Url, +) -> Result { + let status = response.status(); + let reason = status + .canonical_reason() + .unwrap_or("HTTP response") + .to_owned(); + let mut headers = Vec::with_capacity(response.headers().len()); + for (name, value) in response.headers() { + if let Ok(text) = value.to_str() { + headers.push(HttpHeader { + name: name.to_string(), + value: text.to_owned(), + kind: crate::http::HeaderKind::Response, + }); + } + } + + let expected_len = response.content_length(); + let content_range = parse_content_range(&headers); + let partial_content = status.as_u16() == 206; + let direct_write_offset = content_range + .as_ref() + .map(|range| range.start) + .or_else(|| request.range.as_ref().map(|range| range.start)) + .unwrap_or(0); + let (observed_len, temp_path) = if let Some(response_sink) = request.response_sink.as_ref() { + let mut sink = if direct_write_offset == 0 { + OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&response_sink.target_path) + } else { + OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&response_sink.target_path) + } + .map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::Io, + message: format!( + "failed to open direct response sink for {} at {}: {error}", + request.url, + response_sink.target_path.display() + ), + source: Some(error.to_string()), + context: None, + })?; + sink.seek(SeekFrom::Start(direct_write_offset)) + .map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::Io, + message: format!( + "failed to seek direct response sink for {} at {}: {error}", + request.url, + response_sink.target_path.display() + ), + source: Some(error.to_string()), + context: None, + })?; + let observed_len = response + .copy_to(&mut sink) + .map_err(|error| transport_error_from_reqwest(error, request))?; + (observed_len, None) + } else { + let temp_path = temp_stream_sink_path(); + let mut sink = File::create(&temp_path).map_err(|error| TransportError { + kind: crate::transport::TransportErrorKind::Io, + message: format!( + "failed to create streamed sink for {}: {error}", + request.url + ), + source: Some(error.to_string()), + context: None, + })?; + let observed_len = response + .copy_to(&mut sink) + .map_err(|error| transport_error_from_reqwest(error, request))?; + (observed_len, Some(temp_path)) + }; + + Ok(HttpResponseModel { + status: status.as_u16(), + reason, + version: http_version_from_reqwest(response.version()), + headers: HttpResponseHeaders { headers }, + body: ResponseBody::Streamed { + expected_len, + observed_len: Some(observed_len), + observed_digest: None, + temp_path, + }, + content_range, + partial_content, + checksum: None, + redirected_from: (response.url().as_str() != requested_url.as_str()) + .then(|| requested_url.as_str().to_owned()), + }) +} + +/// Maps reqwest's HTTP version enum into the protocol-layer version model. +fn http_version_from_reqwest(version: reqwest::Version) -> HttpVersion { + match version { + reqwest::Version::HTTP_09 | reqwest::Version::HTTP_10 => HttpVersion::Http10, + reqwest::Version::HTTP_2 => HttpVersion::Http2, + reqwest::Version::HTTP_3 => HttpVersion::Http3, + _ => HttpVersion::Http11, + } +} diff --git a/crates/aria2-rust-pro-protocol/src/ftp.rs b/crates/aria2-rust-pro-protocol/src/ftp.rs new file mode 100644 index 0000000..8a396e2 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/ftp.rs @@ -0,0 +1,106 @@ +//! FTP request, response, and session models. + +#![forbid(unsafe_code)] + +use crate::{ + auth::AuthCredentialModel, + http::{HttpHeader, ProxyConfig, RetryStrategy, TlsConfig}, +}; + +/// Transfer mode used by an FTP session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FtpMode { + /// Passive mode where the server accepts the data connection. + Passive, + /// Active mode where the client accepts the data connection. + Active, +} + +/// Connection and retry settings for an FTP endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FtpConfigModel { + /// Remote host name or IP. + pub host: String, + /// Remote control-port number. + pub port: u16, + /// Optional username for login. + pub username: Option, + /// Optional password for login. + pub password: Option, + /// Whether FTPS or other secure transport is expected. + pub secure: bool, + /// Active or passive data-channel mode. + pub mode: FtpMode, + /// Initial working directory after login. + pub initial_cwd: Option, + /// Optional proxy configuration. + pub proxy: Option, + /// Optional TLS tuning parameters. + pub tls: Option, + /// Retry strategy for failed requests. + pub retry: RetryStrategy, +} + +/// FTP command issued within a request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FtpCommandModel { + /// `USER `. + User(String), + /// `PASS `. + Pass(String), + /// `PWD`. + Pwd, + /// `CWD `. + Cwd(String), + /// `LIST [path]`. + List(Option), + /// `SIZE `. + Size(String), + /// `REST `. + Rest(u64), + /// `RETR `. + Retr(String), + /// `QUIT`. + Quit, + /// Caller-supplied custom FTP command text. + Custom(String), +} + +/// FTP session state captured by the protocol layer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FtpSessionModel { + /// Stable session identifier. + pub session_id: String, + /// Resolved endpoint configuration. + pub config: FtpConfigModel, + /// Optional authenticated credential. + pub auth: Option, + /// Default headers propagated into requests. + pub default_headers: Vec, +} + +/// FTP request envelope passed into a connector. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FtpRequestModel { + /// Command to execute. + pub command: FtpCommandModel, + /// Optional path or target associated with the command. + pub path: Option, + /// Additional logical headers attached to the request. + pub headers: Vec, +} + +/// FTP response material returned by a connector. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FtpResponseModel { + /// Numeric FTP status code. + pub code: u16, + /// Human-readable server message. + pub message: String, + /// Optional payload bytes such as directory listings or file contents. + pub data: Option>, + /// Optional path associated with the response. + pub path: Option, + /// Whether the response can carry transferable data. + pub transferable: bool, +} diff --git a/crates/aria2-rust-pro-protocol/src/http.rs b/crates/aria2-rust-pro-protocol/src/http.rs new file mode 100644 index 0000000..ae8b0ae --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/http.rs @@ -0,0 +1,22 @@ +//! HTTP protocol models, transfer state, and checksum helpers. +#![forbid(unsafe_code)] + +/// Checksum parsing and validation helpers for HTTP transfers. +mod checksum; +/// Shared HTTP request and response data models. +mod model; +/// Transfer-progress tracking and aggregation helpers. +mod progress; + +#[cfg(test)] +mod tests; + +pub use self::model::{ + AuthChallenge, AuthCredential, AuthScheme, ChecksumHookModel, ChecksumSpec, ContentRangeSpec, + Cookie, HeaderKind, HttpBody, HttpCompletionModel, HttpCompletionState, HttpHeader, HttpMethod, + HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel, + HttpResponseSinkTarget, HttpRetryAttemptDetailModel, HttpSegmentProgressModel, + HttpSessionModel, HttpTransferProgressModel, HttpTransferTaskModel, HttpVersion, ProxyConfig, + RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy, RetryReason, + RetryStrategy, TlsConfig, +}; diff --git a/crates/aria2-rust-pro-protocol/src/http/checksum.rs b/crates/aria2-rust-pro-protocol/src/http/checksum.rs new file mode 100644 index 0000000..12c45e8 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/http/checksum.rs @@ -0,0 +1,110 @@ +use adler2::Adler32; +use crc32fast::Hasher as Crc32Hasher; +use md5::Md5; +use sha1::Sha1; +use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; + +use super::model::{ChecksumSpec, ResponseBody}; + +impl ChecksumSpec { + #[must_use] + /// Returns whether the observed and expected digests match. + pub fn is_verified(&self) -> bool { + self.actual_hex + .as_deref() + .is_some_and(|actual| actual.eq_ignore_ascii_case(&self.expected_hex)) + } + + #[must_use] + /// Computes the payload digest using the configured algorithm. + pub fn compute_actual_hex(&self, payload: &[u8]) -> Option { + checksum_hex(&self.algorithm, payload) + } + + #[must_use] + /// Returns whether `payload` matches the expected digest when supported. + pub fn verify_payload(&self, payload: &[u8]) -> Option { + self.compute_actual_hex(payload) + .map(|actual| actual.eq_ignore_ascii_case(&self.expected_hex)) + } +} + +/// Compares a streamed body's observed digest with the expected checksum. +pub(super) fn streamed_checksum_verification( + checksum: &ChecksumSpec, + body: &ResponseBody, +) -> Option { + match body { + ResponseBody::Streamed { + observed_digest: Some(actual), + .. + } => Some(actual.eq_ignore_ascii_case(&checksum.expected_hex)), + _ => None, + } +} + +/// Computes a lowercase hexadecimal digest for the requested checksum algorithm. +#[must_use] +pub(super) fn checksum_hex(algorithm: &str, payload: &[u8]) -> Option { + let algorithm = algorithm.trim(); + let digest = if matches_checksum_algorithm(algorithm, &["sha1", "sha-1", "sha"]) { + let mut hasher = Sha1::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if matches_checksum_algorithm(algorithm, &["sha224", "sha-224"]) { + let mut hasher = Sha224::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if matches_checksum_algorithm(algorithm, &["sha256", "sha-256"]) { + let mut hasher = Sha256::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if matches_checksum_algorithm(algorithm, &["sha384", "sha-384"]) { + let mut hasher = Sha384::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if matches_checksum_algorithm(algorithm, &["sha512", "sha-512"]) { + let mut hasher = Sha512::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if algorithm.eq_ignore_ascii_case("md5") { + let mut hasher = Md5::new(); + hasher.update(payload); + hasher.finalize().to_vec() + } else if algorithm.eq_ignore_ascii_case("adler32") { + let mut hasher = Adler32::new(); + hasher.write_slice(payload); + hasher.checksum().to_be_bytes().to_vec() + } else if algorithm.eq_ignore_ascii_case("crc32") { + let mut hasher = Crc32Hasher::new(); + hasher.update(payload); + hasher.finalize().to_be_bytes().to_vec() + } else { + return None; + }; + Some(bytes_to_hex(&digest)) +} + +/// Returns whether a checksum algorithm matches any accepted spelling. +fn matches_checksum_algorithm(algorithm: &str, accepted: &[&str]) -> bool { + accepted + .iter() + .any(|candidate| algorithm.eq_ignore_ascii_case(candidate)) +} + +/// Hex-encodes digest bytes using lowercase hexadecimal. +#[must_use] +fn bytes_to_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} + +/// Saturates a `usize` length into `u64`. +pub(super) fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} diff --git a/crates/aria2-rust-pro-protocol/src/http/model.rs b/crates/aria2-rust-pro-protocol/src/http/model.rs new file mode 100644 index 0000000..bd872b4 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/http/model.rs @@ -0,0 +1,509 @@ +use std::{collections::HashMap, path::PathBuf}; + +pub use crate::auth::{ + AuthChallengeModel as AuthChallenge, AuthCredentialModel as AuthCredential, AuthScheme, +}; + +/// Classifies how one header participates in an HTTP exchange. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HeaderKind { + /// Header belongs to the request. + Request, + /// Header belongs to the response. + Response, + /// Header is valid for both directions. + General, +} + +/// One normalized HTTP header field. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpHeader { + /// Lower-level header name. + pub name: String, + /// Raw header value. + pub value: String, + /// Header classification within the exchange. + pub kind: HeaderKind, +} + +/// HTTP methods supported by the protocol layer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HttpMethod { + /// `GET` + Get, + /// `HEAD` + Head, + /// `POST` + Post, + /// `PUT` + Put, + /// `DELETE` + Delete, +} + +/// HTTP versions surfaced by the transport layer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HttpVersion { + /// HTTP/1.0 + Http10, + /// HTTP/1.1 + Http11, + /// HTTP/2 + Http2, + /// HTTP/3 + Http3, +} + +/// One request byte or piece range. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RangeSpec { + /// Inclusive start offset. + pub start: u64, + /// Optional inclusive end offset. + pub end_inclusive: Option, + /// Unit used by the range. + pub unit: RangeUnit, +} + +impl RangeSpec { + #[must_use] + /// Returns whether the range omits an explicit end bound. + pub const fn is_open_ended(&self) -> bool { + self.end_inclusive.is_none() + } + + #[must_use] + /// Returns the requested length when the end bound is known. + pub fn length_hint(&self) -> Option { + self.end_inclusive + .map(|end| end.saturating_sub(self.start).saturating_add(1)) + } +} + +/// Units supported by HTTP-style range models. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RangeUnit { + /// Byte-oriented ranges. + Bytes, + /// Piece-oriented ranges used by higher-level scheduling. + Pieces, +} + +/// Parsed `Content-Range` response metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ContentRangeSpec { + /// Unit reported by the server. + pub unit: RangeUnit, + /// Inclusive start offset returned by the server. + pub start: u64, + /// Inclusive end offset returned by the server. + pub end_inclusive: u64, + /// Total object size when known. + pub total_size: Option, + /// Whether the response represents an unsatisfied range. + pub unsatisfied: bool, +} + +impl ContentRangeSpec { + #[must_use] + /// Returns the completed length implied by the range payload. + pub const fn completed_length(&self) -> u64 { + if self.unsatisfied { + 0 + } else { + self.end_inclusive.saturating_add(1) + } + } + + #[must_use] + /// Returns whether the range is explicitly unsatisfied. + pub const fn is_unsatisfied(&self) -> bool { + self.unsatisfied + } +} + +/// Resume metadata carried into one HTTP transfer attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResumeState { + /// Requested starting offset for the retry or resumed request. + pub requested_offset: u64, + /// Offset actually accepted by the remote server. + pub accepted_offset: Option, + /// Whether the attempt truly resumed instead of restarting from zero. + pub resumed: bool, +} + +/// Retry policy knobs applied to HTTP work. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetryPolicy { + /// Maximum number of attempts. + pub max_attempts: u32, + /// Initial backoff delay in milliseconds. + pub initial_backoff_ms: u64, + /// Maximum backoff delay in milliseconds. + pub max_backoff_ms: u64, + /// Whether `3xx` responses are retryable. + pub retry_on_3xx: bool, + /// Whether `4xx` responses are retryable. + pub retry_on_4xx: bool, + /// Whether `5xx` responses are retryable. + pub retry_on_5xx: bool, + /// Whether transport-level network errors are retryable. + pub retry_on_network_error: bool, + /// Whether timeout failures are retryable. + pub retry_on_timeout: bool, +} + +/// Fully-resolved retry behavior for one request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetryStrategy { + /// Base retry policy. + pub policy: RetryPolicy, + /// Optional jitter value in milliseconds. + pub jitter: Option, + /// Optional upper bound on total retry elapsed time in milliseconds. + pub max_elapsed_ms: Option, +} + +/// Normalized reasons for retrying one transfer attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetryReason { + /// A transport-level network failure occurred. + NetworkError, + /// The request timed out. + Timeout, + /// The server responded with a retryable `3xx`. + Http3xx, + /// The server responded with a retryable `4xx`. + Http4xx, + /// The server responded with a retryable `5xx`. + Http5xx, + /// Partial-content semantics did not match the requested resume state. + PartialContentMismatch, + /// Another retryable condition occurred. + Other, +} + +/// One recorded retry attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetryAttempt { + /// Attempt number starting at one. + pub attempt: u32, + /// Retry reason for the attempt. + pub reason: RetryReason, + /// Optional HTTP status observed during the attempt. + pub status: Option, + /// Optional backoff delay in milliseconds before the next attempt. + pub backoff_ms: Option, +} + +/// Final or intermediate completion state for one HTTP response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HttpCompletionState { + /// Response is not yet complete. + Incomplete, + /// Response is usable but only partial. + Partial, + /// Response is complete without checksum verification. + Complete, + /// Response is complete and checksum-verified. + Verified, +} + +/// Derived completion summary for one HTTP response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpCompletionModel { + /// Overall completion state. + pub state: HttpCompletionState, + /// Total payload length when known. + pub total_length: Option, + /// Number of completed bytes. + pub completed_length: u64, + /// Whether the response used partial-content semantics. + pub partial_content: bool, + /// Whether the status code indicates terminal success. + pub terminal_success: bool, + /// Whether checksum metadata was present. + pub checksum_seen: bool, + /// Whether the checksum could be verified successfully. + pub checksum_verified: bool, +} + +/// Segment-level progress view for one transfer snapshot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpSegmentProgressModel { + /// Range originally requested from the server. + pub requested_range: Option, + /// Requested starting offset. + pub requested_offset: u64, + /// Offset accepted by the server when present. + pub accepted_offset: Option, + /// Completed offset derived from the current response. + pub completed_offset: Option, + /// Whether the transfer is actively resuming instead of restarting. + pub resumed: bool, +} + +/// Retry-attempt detail enriched with segment and resume context. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpRetryAttemptDetailModel { + /// Base retry-attempt data. + pub base: RetryAttempt, + /// Range requested for the attempt. + pub requested_range: Option, + /// Requested starting offset for the attempt. + pub requested_offset: u64, + /// Offset accepted by the server when present. + pub accepted_offset: Option, + /// Resume metadata captured for the attempt. + pub resume_state: Option, +} + +/// Snapshot of one in-flight or completed HTTP transfer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpTransferProgressModel { + /// Stable task identifier. + pub task_id: String, + /// Request model associated with the transfer. + pub request: HttpRequestModel, + /// Segment-level progress details. + pub segment: HttpSegmentProgressModel, + /// Retry-attempt history with contextual detail. + pub retry_attempts: Vec, + /// Maximum number of concurrent connections permitted for the task. + pub max_connections: u16, + /// Optional checksum hook attached to the transfer. + pub checksum_hook: Option, + /// Derived completion summary when a response exists. + pub completion: Option, +} + +/// Proxy configuration projected into HTTP requests. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProxyConfig { + /// Proxy scheme such as `http` or `socks5`. + pub scheme: String, + /// Proxy host name or IP. + pub host: String, + /// Proxy port. + pub port: u16, + /// Optional proxy username. + pub username: Option, + /// Optional proxy password. + pub password: Option, + /// Hosts that should bypass the proxy. + pub bypass_hosts: Vec, + /// Whether proxying is disabled for the request. + pub no_proxy: bool, +} + +/// One normalized HTTP cookie. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Cookie { + /// Cookie name. + pub name: String, + /// Cookie value. + pub value: String, + /// Optional domain constraint. + pub domain: Option, + /// Optional path constraint. + pub path: Option, + /// Whether the cookie requires a secure transport. + pub secure: bool, + /// Whether the cookie is `HttpOnly`. + pub http_only: bool, + /// Optional same-site policy marker. + pub same_site: Option, + /// Expiration time as a Unix timestamp when present. + pub expires_unix_epoch: Option, +} + +/// TLS behavior attached to one HTTP session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TlsConfig { + /// Whether peer certificates must be verified. + pub verify_peer: bool, + /// Whether host name verification is enabled. + pub verify_host: bool, + /// Minimum TLS version when constrained. + pub min_version: Option, + /// Maximum TLS version when constrained. + pub max_version: Option, + /// Optional CA bundle path. + pub ca_file: Option, + /// Optional client certificate path. + pub cert_file: Option, + /// Optional client key path. + pub key_file: Option, +} + +/// Ordered collection of request headers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpRequestHeaders { + /// Stored request headers. + pub headers: Vec, +} + +/// Ordered collection of response headers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpResponseHeaders { + /// Stored response headers. + pub headers: Vec, +} + +/// Request-body representation for HTTP transfers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HttpBody { + /// No request body. + Empty, + /// UTF-8 text request body. + Text(String), + /// Arbitrary binary request body. + Binary(Vec), + /// Streaming body with an optional declared length. + Stream { + /// Declared body length when the caller knows it. + expected_len: Option, + }, +} + +/// Expected and observed checksum metadata for one payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ChecksumSpec { + /// Hash algorithm name. + pub algorithm: String, + /// Expected digest hex string. + pub expected_hex: String, + /// Observed digest hex string when known. + pub actual_hex: Option, +} + +/// Optional checksum hook attached to a transfer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ChecksumHookModel { + /// Checksum specification to evaluate. + pub spec: ChecksumSpec, + /// Whether the hook is enabled. + pub enabled: bool, +} + +/// Optional direct-write target for one live HTTP response body. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpResponseSinkTarget { + /// Final output path that should receive the response body directly. + pub target_path: PathBuf, +} + +/// Fully normalized HTTP request model. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpRequestModel { + /// HTTP method. + pub method: HttpMethod, + /// Fully-qualified request URL. + pub url: String, + /// Requested HTTP version. + pub version: HttpVersion, + /// Explicit request headers. + pub headers: HttpRequestHeaders, + /// Query parameters to attach to the URL. + pub query: HashMap, + /// Optional range metadata. + pub range: Option, + /// Request body. + pub body: HttpBody, + /// Retry strategy for the request. + pub retry: RetryStrategy, + /// Optional origin credential. + pub auth: Option, + /// Optional proxy configuration. + pub proxy: Option, + /// Optional direct-write sink for live response persistence. + pub response_sink: Option, +} + +/// Session-scoped defaults that shape HTTP execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpSessionModel { + /// Stable session identifier. + pub session_id: String, + /// Optional user-agent string. + pub user_agent: Option, + /// Default headers applied to requests. + pub default_headers: Vec, + /// Cookies carried by the session. + pub cookies: Vec, + /// Optional default credential. + pub auth: Option, + /// Optional default proxy configuration. + pub proxy: Option, + /// Optional TLS behavior for the session. + pub tls: Option, + /// Default retry strategy for the session. + pub retry: RetryStrategy, +} + +/// Response-body representation used by the protocol layer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResponseBody { + /// No response payload. + Empty, + /// Inline retained payload bytes. + Inline(Vec), + /// Streamed payload metadata with optional retained artifacts. + Streamed { + /// Declared content length when known. + expected_len: Option, + /// Observed byte count written through the sink. + observed_len: Option, + /// Observed digest when computed by the sink. + observed_digest: Option, + /// Optional temporary file path holding the streamed body. + temp_path: Option, + }, +} + +/// Executable HTTP transfer task passed into downloaders. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpTransferTaskModel { + /// Stable task identifier. + pub task_id: String, + /// Request model for the task. + pub request: HttpRequestModel, + /// Response headers already associated with the task. + pub response_headers: HttpResponseHeaders, + /// Current response body state. + pub body: ResponseBody, + /// Resume metadata when resuming is in play. + pub resume_state: Option, + /// Retry-attempt history. + pub retry_attempts: Vec, + /// Optional checksum hook. + pub checksum_hook: Option, + /// Maximum allowed concurrent connections. + pub max_connections: u16, + /// Retry strategy for the task. + pub retry: RetryStrategy, +} + +/// Normalized HTTP response model produced by connectors and fixtures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpResponseModel { + /// Numeric HTTP status code. + pub status: u16, + /// Human-readable reason phrase. + pub reason: String, + /// Negotiated HTTP version. + pub version: HttpVersion, + /// Response headers. + pub headers: HttpResponseHeaders, + /// Response body representation. + pub body: ResponseBody, + /// Parsed `Content-Range` metadata when present. + pub content_range: Option, + /// Whether the response used partial-content semantics. + pub partial_content: bool, + /// Optional checksum metadata. + pub checksum: Option, + /// Original URL before redirects when one occurred. + pub redirected_from: Option, +} diff --git a/crates/aria2-rust-pro-protocol/src/http/progress.rs b/crates/aria2-rust-pro-protocol/src/http/progress.rs new file mode 100644 index 0000000..3936564 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/http/progress.rs @@ -0,0 +1,177 @@ +use super::{ + checksum::{streamed_checksum_verification, usize_to_u64}, + model::{ + HttpCompletionModel, HttpCompletionState, HttpResponseModel, HttpRetryAttemptDetailModel, + HttpSegmentProgressModel, HttpTransferProgressModel, HttpTransferTaskModel, ResponseBody, + }, +}; + +impl HttpTransferTaskModel { + #[must_use] + /// Returns the effective requested offset for the task. + pub fn requested_offset(&self) -> u64 { + self.resume_state + .as_ref() + .map(|state| state.requested_offset) + .or_else(|| self.request.range.as_ref().map(|range| range.start)) + .unwrap_or_default() + } + + #[must_use] + /// Returns retry attempts enriched with resume and range context. + pub fn retry_attempt_details(&self) -> Vec { + let requested_offset = self.requested_offset(); + let requested_range = self.request.range; + let accepted_offset = self + .resume_state + .as_ref() + .and_then(|state| state.accepted_offset); + let resume_state = self.resume_state; + + self.retry_attempts + .iter() + .copied() + .map(|base| HttpRetryAttemptDetailModel { + base, + requested_range, + requested_offset, + accepted_offset, + resume_state, + }) + .collect() + } + + #[must_use] + /// Builds a progress snapshot from the current task and optional response. + pub fn progress_snapshot( + &self, + response: Option<&HttpResponseModel>, + ) -> HttpTransferProgressModel { + let completion = response.map(HttpResponseModel::completion_model); + let completed_offset = response.map(HttpResponseModel::completed_length); + let (accepted_offset, resumed) = response.map_or_else( + || { + ( + self.resume_state + .as_ref() + .and_then(|state| state.accepted_offset), + self.resume_state + .as_ref() + .is_some_and(|state| state.resumed), + ) + }, + |response| { + if response.status == 206 { + let accepted_offset = response + .content_range + .as_ref() + .and_then(|range| (!range.is_unsatisfied()).then_some(range.start)); + ( + accepted_offset, + accepted_offset.is_some() + || self + .resume_state + .as_ref() + .is_some_and(|state| state.resumed), + ) + } else { + (None, false) + } + }, + ); + + HttpTransferProgressModel { + task_id: self.task_id.clone(), + request: self.request.clone(), + segment: HttpSegmentProgressModel { + requested_range: self.request.range, + requested_offset: self.requested_offset(), + accepted_offset, + completed_offset, + resumed, + }, + retry_attempts: self.retry_attempt_details(), + max_connections: self.max_connections, + checksum_hook: self.checksum_hook.clone(), + completion, + } + } +} + +impl HttpResponseModel { + #[must_use] + /// Returns the total payload length when the response exposes it. + pub fn total_length(&self) -> Option { + self.content_range + .as_ref() + .and_then(|range| range.total_size) + .or_else(|| match &self.body { + ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())), + ResponseBody::Streamed { + expected_len, + observed_len, + .. + } => expected_len.or(*observed_len), + ResponseBody::Empty => None, + }) + } + + #[must_use] + /// Returns the completed payload length represented by the response. + pub fn completed_length(&self) -> u64 { + self.content_range + .as_ref() + .map(super::model::ContentRangeSpec::completed_length) + .or_else(|| match &self.body { + ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())), + ResponseBody::Streamed { observed_len, .. } => *observed_len, + ResponseBody::Empty => Some(0), + }) + .unwrap_or_default() + } + + #[must_use] + /// Returns inline body bytes when they are retained in memory. + pub fn body_bytes(&self) -> Option<&[u8]> { + match &self.body { + ResponseBody::Empty => Some(&[]), + ResponseBody::Inline(bytes) => Some(bytes), + ResponseBody::Streamed { .. } => None, + } + } + + #[must_use] + /// Derives a completion summary from the response payload and metadata. + pub fn completion_model(&self) -> HttpCompletionModel { + let total_length = self.total_length(); + let completed_length = self.completed_length(); + let checksum_seen = self.checksum.is_some(); + let checksum_verified = self.checksum.as_ref().is_some_and(|checksum| { + self.body_bytes() + .and_then(|bytes| checksum.verify_payload(bytes)) + .or_else(|| streamed_checksum_verification(checksum, &self.body)) + .unwrap_or_else(|| checksum.is_verified()) + }); + let terminal_success = (200..300).contains(&self.status); + let complete_enough = total_length.is_none_or(|total| completed_length >= total); + let state = if !terminal_success { + HttpCompletionState::Incomplete + } else if checksum_verified && complete_enough { + HttpCompletionState::Verified + } else if complete_enough { + HttpCompletionState::Complete + } else { + HttpCompletionState::Partial + }; + + HttpCompletionModel { + state, + total_length, + completed_length, + partial_content: self.partial_content, + terminal_success, + checksum_seen, + checksum_verified, + } + } +} diff --git a/crates/aria2-rust-pro-protocol/src/http/tests.rs b/crates/aria2-rust-pro-protocol/src/http/tests.rs new file mode 100644 index 0000000..f7803ff --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/http/tests.rs @@ -0,0 +1,489 @@ +use std::collections::HashMap; + +use super::*; + +fn sample_retry_strategy() -> RetryStrategy { + RetryStrategy { + policy: RetryPolicy { + max_attempts: 5, + initial_backoff_ms: 100, + max_backoff_ms: 5_000, + retry_on_3xx: false, + retry_on_4xx: false, + retry_on_5xx: true, + retry_on_network_error: true, + retry_on_timeout: true, + }, + jitter: Some(25), + max_elapsed_ms: Some(60_000), + } +} + +fn sample_request() -> HttpRequestModel { + HttpRequestModel { + method: HttpMethod::Get, + url: "https://example.invalid/file.bin".to_string(), + version: HttpVersion::Http11, + headers: HttpRequestHeaders { headers: vec![] }, + query: HashMap::new(), + range: Some(RangeSpec { + start: 4096, + end_inclusive: None, + unit: RangeUnit::Bytes, + }), + body: HttpBody::Empty, + retry: sample_retry_strategy(), + auth: None, + proxy: None, + response_sink: None, + } +} + +#[test] +fn response_model_carries_partial_content_and_content_range() { + let response = HttpResponseModel { + status: 206, + reason: "Partial Content".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: Some(1024), + observed_len: Some(1024), + observed_digest: None, + temp_path: None, + }, + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 4096, + end_inclusive: 5119, + total_size: Some(10_000), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + + assert!(response.partial_content); + assert_eq!( + response.content_range, + Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 4096, + end_inclusive: 5119, + total_size: Some(10_000), + unsatisfied: false, + }) + ); +} + +#[test] +fn response_completion_model_distinguishes_partial_and_verified() { + let partial = HttpResponseModel { + status: 206, + reason: "Partial Content".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Inline(b"12345".to_vec()), + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 0, + end_inclusive: 4, + total_size: Some(10), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + let verified = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Inline(b"abc".to_vec()), + content_range: None, + partial_content: false, + checksum: Some(ChecksumSpec { + algorithm: "sha-1".to_string(), + expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), + actual_hex: None, + }), + redirected_from: None, + }; + + assert_eq!( + partial.completion_model().state, + HttpCompletionState::Partial + ); + assert_eq!(partial.completion_model().completed_length, 5); + assert_eq!( + verified.completion_model().state, + HttpCompletionState::Verified + ); + assert!(verified.completion_model().checksum_verified); +} + +#[test] +fn range_rejection_keeps_total_length_truth_without_reporting_progress() { + let response = HttpResponseModel { + status: 416, + reason: "Range Not Satisfiable".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Empty, + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 0, + end_inclusive: 0, + total_size: Some(8192), + unsatisfied: true, + }), + partial_content: false, + checksum: None, + redirected_from: None, + }; + + let completion = response.completion_model(); + assert_eq!(completion.total_length, Some(8192)); + assert_eq!(completion.completed_length, 0); + assert_eq!(completion.state, HttpCompletionState::Incomplete); +} + +#[test] +fn checksum_verification_uses_inline_payload_bytes() { + let checksum = ChecksumSpec { + algorithm: "sha-256".to_string(), + expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + .to_string(), + actual_hex: None, + }; + + assert_eq!(checksum.verify_payload(b"abc"), Some(true)); + assert_eq!(checksum.verify_payload(b"abcd"), Some(false)); +} + +#[test] +fn streamed_response_completion_uses_observed_length_and_digest() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: Some(12), + observed_len: Some(12), + observed_digest: Some("9251ad9cddb52f55d2c6b96280c781e7".to_string()), + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: Some(ChecksumSpec { + algorithm: "md5".to_string(), + expected_hex: "9251ad9cddb52f55d2c6b96280c781e7".to_string(), + actual_hex: None, + }), + redirected_from: None, + }; + + let completion = response.completion_model(); + assert_eq!(completion.completed_length, 12); + assert_eq!(completion.total_length, Some(12)); + assert_eq!(completion.state, HttpCompletionState::Verified); + assert!(completion.checksum_verified); +} + +#[test] +fn streamed_completion_uses_observed_len_without_expected_len() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: Some(4096), + observed_digest: None, + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + let completion = response.completion_model(); + assert_eq!(completion.total_length, Some(4096)); + assert_eq!(completion.completed_length, 4096); + assert_eq!(completion.state, HttpCompletionState::Complete); +} + +#[test] +fn streamed_completion_does_not_treat_expected_len_as_completed_len() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: Some(4096), + observed_len: None, + observed_digest: None, + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + let completion = response.completion_model(); + assert_eq!(completion.total_length, Some(4096)); + assert_eq!(completion.completed_length, 0); + assert_eq!(completion.state, HttpCompletionState::Partial); +} + +#[test] +fn streamed_checksum_verifies_from_observed_digest_without_inline_body() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: Some(128), + observed_digest: Some( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(), + ), + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: Some(ChecksumSpec { + algorithm: "sha-256".to_string(), + expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + .to_string(), + actual_hex: None, + }), + redirected_from: None, + }; + + let completion = response.completion_model(); + assert!(completion.checksum_seen); + assert!(completion.checksum_verified); + assert_eq!(completion.state, HttpCompletionState::Verified); +} + +#[test] +fn transfer_task_tracks_resume_offsets() { + let task = HttpTransferTaskModel { + task_id: "task-resume-1".to_string(), + request: sample_request(), + response_headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: None, + observed_digest: None, + temp_path: None, + }, + resume_state: Some(ResumeState { + requested_offset: 8192, + accepted_offset: Some(8192), + resumed: true, + }), + retry_attempts: vec![], + checksum_hook: None, + max_connections: 4, + retry: sample_retry_strategy(), + }; + + let resume_state = task.resume_state.expect("resume state should exist"); + assert!(resume_state.resumed); + assert_eq!(resume_state.requested_offset, 8192); + assert_eq!(resume_state.accepted_offset, Some(8192)); +} + +#[test] +fn transfer_task_tracks_retry_attempt_history() { + let task = HttpTransferTaskModel { + task_id: "task-retry-1".to_string(), + request: sample_request(), + response_headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: None, + observed_digest: None, + temp_path: None, + }, + resume_state: None, + retry_attempts: vec![ + RetryAttempt { + attempt: 1, + reason: RetryReason::Timeout, + status: None, + backoff_ms: Some(100), + }, + RetryAttempt { + attempt: 2, + reason: RetryReason::Http5xx, + status: Some(503), + backoff_ms: Some(250), + }, + ], + checksum_hook: None, + max_connections: 4, + retry: sample_retry_strategy(), + }; + + assert_eq!(task.retry_attempts.len(), 2); + assert_eq!(task.retry_attempts[0].reason, RetryReason::Timeout); + assert_eq!(task.retry_attempts[1].status, Some(503)); + assert_eq!(task.retry_attempts[1].backoff_ms, Some(250)); +} + +#[test] +fn transfer_task_progress_snapshot_tracks_segment_progress_and_retry_context() { + let request = HttpRequestModel { + method: HttpMethod::Get, + url: "https://example.invalid/segment.bin".to_string(), + version: HttpVersion::Http11, + headers: HttpRequestHeaders { headers: vec![] }, + query: HashMap::new(), + range: Some(RangeSpec { + start: 8192, + end_inclusive: Some(12_287), + unit: RangeUnit::Bytes, + }), + body: HttpBody::Empty, + retry: sample_retry_strategy(), + auth: None, + proxy: None, + response_sink: None, + }; + let task = HttpTransferTaskModel { + task_id: "task-progress-1".to_string(), + request, + response_headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: None, + observed_digest: None, + temp_path: None, + }, + resume_state: Some(ResumeState { + requested_offset: 8192, + accepted_offset: Some(8192), + resumed: true, + }), + retry_attempts: vec![ + RetryAttempt { + attempt: 1, + reason: RetryReason::Timeout, + status: None, + backoff_ms: Some(100), + }, + RetryAttempt { + attempt: 2, + reason: RetryReason::Http5xx, + status: Some(503), + backoff_ms: Some(250), + }, + ], + checksum_hook: Some(ChecksumHookModel { + spec: ChecksumSpec { + algorithm: "sha-256".to_string(), + expected_hex: "abc123".to_string(), + actual_hex: None, + }, + enabled: true, + }), + max_connections: 4, + retry: sample_retry_strategy(), + }; + let response = HttpResponseModel { + status: 206, + reason: "Partial Content".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Inline(b"abcdefghijkl".to_vec()), + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 8192, + end_inclusive: 12_203, + total_size: Some(16_384), + unsatisfied: false, + }), + partial_content: true, + checksum: Some(ChecksumSpec { + algorithm: "sha-256".to_string(), + expected_hex: "abc123".to_string(), + actual_hex: Some("abc123".to_string()), + }), + redirected_from: None, + }; + + let progress = task.progress_snapshot(Some(&response)); + + assert_eq!(progress.task_id, "task-progress-1"); + assert_eq!(progress.segment.requested_offset, 8192); + assert_eq!(progress.segment.accepted_offset, Some(8192)); + assert_eq!(progress.segment.completed_offset, Some(12_204)); + assert!(progress.segment.resumed); + assert_eq!(progress.retry_attempts.len(), 2); + assert_eq!(progress.retry_attempts[0].requested_offset, 8192); + assert_eq!(progress.retry_attempts[1].accepted_offset, Some(8192)); + assert_eq!( + progress + .completion + .as_ref() + .expect("completion should be present") + .state, + HttpCompletionState::Partial + ); +} + +#[test] +fn progress_snapshot_clears_resume_truth_when_server_ignores_requested_range() { + let task = HttpTransferTaskModel { + task_id: "task-progress-range-ignored".to_string(), + request: sample_request(), + response_headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Empty, + resume_state: Some(ResumeState { + requested_offset: 4096, + accepted_offset: Some(4096), + resumed: true, + }), + retry_attempts: vec![], + checksum_hook: None, + max_connections: 1, + retry: sample_retry_strategy(), + }; + let response = HttpResponseModel { + status: 200, + reason: "OK".to_string(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { headers: vec![] }, + body: ResponseBody::Inline(vec![b'x'; 16_384]), + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + let progress = task.progress_snapshot(Some(&response)); + + assert_eq!(progress.segment.requested_offset, 4096); + assert_eq!(progress.segment.accepted_offset, None); + assert_eq!(progress.segment.completed_offset, Some(16_384)); + assert!(!progress.segment.resumed); + assert_eq!( + progress + .completion + .as_ref() + .expect("completion should exist") + .state, + HttpCompletionState::Complete + ); +} diff --git a/crates/aria2-rust-pro-protocol/src/lib.rs b/crates/aria2-rust-pro-protocol/src/lib.rs new file mode 100644 index 0000000..0b37e2b --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/lib.rs @@ -0,0 +1,134 @@ +//! Protocol-layer models and helpers for the `aria2-rust-pro` workspace. +//! +//! This crate centralizes transport-facing request/response types plus parser +//! and serialization helpers shared by higher-level crates. +#![forbid(unsafe_code)] +#![expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + clippy::integer_division, + clippy::missing_const_for_fn, + clippy::missing_errors_doc, + clippy::module_name_repetitions, + clippy::multiple_crate_versions, + clippy::needless_pass_by_value, + clippy::result_large_err, + clippy::struct_excessive_bools, + reason = "protocol models intentionally mirror aria2 wire/config semantics where strict style lints obscure compatibility" +)] + +/// Authentication challenge and credential models. +pub mod auth; +/// BitTorrent-facing re-exports and compatibility aliases. +pub mod bt; +/// Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models. +pub mod bt_metalink; +/// Downloader traits and transport-backed implementations. +pub mod downloader; +/// FTP protocol request, response, and configuration models. +pub mod ftp; +/// HTTP protocol models, transfer state, and checksum helpers. +pub mod http; +/// Magnet URI parsing and serialization helpers. +pub mod magnet; +/// Metalink document parsing and resource selection helpers. +pub mod metalink; +/// Session-scoped transport and preference models. +pub mod session; +/// SFTP protocol request, response, and configuration models. +pub mod sftp; +/// Torrent metadata, peer-wire, and DHT message models. +pub mod torrent; +/// Tracker and DHT request parsing plus transport helpers. +pub mod tracker; +/// Generic transport connector abstractions and error models. +pub mod transport; + +/// Logical protocol families recognized by the protocol layer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Protocol { + /// Plain HTTP transfers. + Http, + /// HTTP transfers over TLS. + Https, + /// FTP transfers. + Ftp, + /// SFTP transfers over SSH. + Sftp, + /// Metalink document processing. + Metalink, + /// `.torrent`-backed `BitTorrent` transfers. + BitTorrent, + /// Magnet URI bootstraps for `BitTorrent` transfers. + Magnet, + /// Local file inputs. + File, +} + +impl Protocol { + /// Returns the canonical lowercase protocol name used in serialized forms. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + Self::Ftp => "ftp", + Self::Sftp => "sftp", + Self::Metalink => "metalink", + Self::BitTorrent => "bittorrent", + Self::Magnet => "magnet", + Self::File => "file", + } + } +} + +pub use auth::{AuthChallengeModel, AuthCredentialModel, AuthScheme}; +pub use bt_metalink::{ + BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix, + ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix, +}; +pub use downloader::{ + AuthProvider, ChecksumVerifier, Downloader, FixtureHttpDownloader, FtpConnector, HttpConnector, + HttpOnlyDownloader, HttpsConnector, MetalinkConnector, ReqwestHttpConnector, + RetryStrategyProvider, SftpConnector, TorrentConnector, +}; +pub use ftp::{ + FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, FtpResponseModel, FtpSessionModel, +}; +pub use http::{ + ChecksumHookModel, ChecksumSpec, ContentRangeSpec, Cookie, HeaderKind, HttpBody, + HttpCompletionState, HttpHeader, HttpMethod, HttpRequestHeaders, HttpRequestModel, + HttpResponseHeaders, HttpResponseModel, HttpSessionModel, HttpTransferTaskModel, HttpVersion, + ProxyConfig, RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy, + RetryReason, RetryStrategy, TlsConfig, +}; +pub use magnet::{MagnetMetadataModel, MagnetUriModel}; +pub use metalink::{ + MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult, + MetalinkParserModel, MetalinkResourceModel, metalink_download_plan, parse_metalink_document, + preferred_download_candidate, preferred_resource_for_file, +}; +pub use session::{ + ClientModel, ServerModel, ServerSessionModel, SessionLimits, SessionModel, SessionScope, + SessionState, SessionTransportPreference, +}; +pub use sftp::{ + SftpCommandModel, SftpConfigModel, SftpRequestModel, SftpResponseModel, SftpSessionModel, +}; +pub use torrent::{ + DhtMessageModel, PeerWireMessageModel, TorrentFileEntryModel, TorrentHashModel, + TorrentInfoModel, TorrentMessageModel, TorrentMetadataModel, TorrentPeerModel, + TorrentPieceModel, TorrentTrackerModel, parse_torrent_metadata, +}; +pub use tracker::{ + DhtNodeModel, DhtTransport, ReqwestTrackerTransport, TrackerParseError, TrackerPeerListModel, + TrackerRequestModel, TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel, + TrackerTransport, +}; +pub use transport::{ + PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, + StdDhtTransport, StdTcpPeerWireTransportConnector, StdUdpTransportConnector, TransportBody, + TransportConnector, TransportEndpoint, TransportError, TransportErrorContext, + TransportErrorKind, TransportRequest, TransportResponse, TransportResult, TransportScheme, + TransportStream, UdpTransportConnector, UdpTransportRequest, UdpTransportResponse, +}; diff --git a/crates/aria2-rust-pro-protocol/src/magnet.rs b/crates/aria2-rust-pro-protocol/src/magnet.rs new file mode 100644 index 0000000..c6e6238 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/magnet.rs @@ -0,0 +1,584 @@ +//! Magnet URI parsing and serialization helpers. + +#![forbid(unsafe_code)] + +use crate::bt_metalink::BtMetalinkError; +use crate::{ + torrent::{PeerWireExtensionHandshakeModel, TorrentPeerModel, TorrentTrackerModel}, + tracker::DhtNodeModel, +}; + +/// Parsed representation of a magnet URI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MagnetUriModel { + /// `BitTorrent` info-hash extracted from `xt=urn:btih:...`. + pub info_hash: String, + /// Optional display name from `dn=`. + pub display_name: Option, + /// Tracker URLs from `tr=`. + pub trackers: Vec, + /// Web-seed URLs from `ws=`. + pub web_seeds: Vec, + /// Optional exact-topic or keyword field from `kt=`/`x.pe=`. + pub exact_topic: Option, +} + +/// Higher-level magnet metadata used by callers that already know payload size. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MagnetMetadataModel { + /// Canonical parsed URI. + pub uri: MagnetUriModel, + /// Known payload length when available. + pub known_length: Option, + /// Additional origin or source descriptors. + pub sources: Vec, +} + +/// Fully-shaped magnet bootstrap data ready for CLI or dispatcher orchestration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MagnetBootstrapModel { + /// Canonical parsed magnet URI model. + pub uri: MagnetUriModel, + /// Lowercase hexadecimal 20-byte `BitTorrent` info-hash. + pub info_hash_hex: String, + /// Raw 20-byte `BitTorrent` info-hash. + pub info_hash_bytes: [u8; 20], + /// Tracker rows shaped with stable tier indices. + pub trackers: Vec, + /// Parsed peer endpoints from repeated `x.pe=` hints. + pub peer_hints: Vec, + /// The same `x.pe=` hints re-shaped as DHT/bootstrap nodes. + pub peer_hint_nodes: Vec, +} + +impl MagnetUriModel { + /// Parses a magnet URI into the protocol model. + /// + /// # Errors + /// + /// Returns an error when the input is not a valid magnet URI. + pub fn from_uri(input: &str) -> Result { + parse_magnet_uri(input) + } + + /// Returns the number of embedded tracker URLs. + #[must_use] + pub fn tracker_count(&self) -> usize { + self.trackers.len() + } + + /// Decodes the magnet `btih` token into its raw 20-byte info-hash. + /// + /// # Errors + /// + /// Returns an error when the magnet does not contain a valid hexadecimal or base32 BTIH. + pub fn info_hash_bytes(&self) -> Result<[u8; 20], BtMetalinkError> { + decode_btih_token(&self.info_hash) + } + + /// Returns the info-hash normalized to lowercase hexadecimal. + /// + /// # Errors + /// + /// Returns an error when the stored BTIH token is not a valid `BitTorrent` info-hash. + pub fn canonical_info_hash_hex(&self) -> Result { + self.info_hash_bytes().map(|hash| hex_encode_lower(&hash)) + } + + /// Shapes tracker URLs into stable tier-indexed tracker rows. + #[must_use] + pub fn tracker_models(&self) -> Vec { + self.trackers + .iter() + .enumerate() + .map(|(index, tracker)| TorrentTrackerModel { + url: tracker.clone(), + tier: Some(u32::try_from(index).unwrap_or(u32::MAX)), + id: None, + seeders: None, + leechers: None, + }) + .collect() + } + + /// Builds orchestration-oriented bootstrap data from the parsed magnet model. + /// + /// This variant only uses data preserved by [`MagnetUriModel`], so peer hints are empty. + /// + /// # Errors + /// + /// Returns an error when the stored BTIH token is invalid. + pub fn bootstrap(&self) -> Result { + Ok(MagnetBootstrapModel { + uri: self.clone(), + info_hash_hex: self.canonical_info_hash_hex()?, + info_hash_bytes: self.info_hash_bytes()?, + trackers: self.tracker_models(), + peer_hints: Vec::new(), + peer_hint_nodes: Vec::new(), + }) + } + + /// Serializes the model back into a magnet URI string. + #[must_use] + pub fn to_uri(&self) -> String { + let mut query = Vec::new(); + query.push(format!( + "xt={}", + percent_encode_query_value(&format!("urn:btih:{}", self.info_hash)) + )); + if let Some(display_name) = &self.display_name { + query.push(format!("dn={}", percent_encode_query_value(display_name))); + } + for tracker in &self.trackers { + query.push(format!("tr={}", percent_encode_query_value(tracker))); + } + for web_seed in &self.web_seeds { + query.push(format!("ws={}", percent_encode_query_value(web_seed))); + } + if let Some(exact_topic) = &self.exact_topic { + query.push(format!("kt={}", percent_encode_query_value(exact_topic))); + } + format!("magnet:?{}", query.join("&")) + } +} + +impl MagnetMetadataModel { + /// Wraps a parsed URI together with an optional known payload length. + #[must_use] + pub fn from_uri(uri: MagnetUriModel, known_length: Option) -> Self { + Self { + uri, + known_length, + sources: Vec::new(), + } + } + + /// Updates the known metadata length from an extended handshake when advertised. + pub fn apply_extension_handshake(&mut self, handshake: &PeerWireExtensionHandshakeModel) { + if let Some(metadata_size) = handshake.metadata_size { + self.known_length = Some(u64::from(metadata_size)); + } + } + + /// Serializes the wrapped URI back into a magnet string. + #[must_use] + pub fn to_uri(&self) -> String { + self.uri.to_uri() + } +} + +/// Parses a magnet URI string into a [`MagnetUriModel`]. +/// +/// # Errors +/// +/// Returns an error when the URI is missing the `magnet:?` prefix or a valid +/// `xt=urn:btih:` entry. +pub fn parse_magnet_uri(input: &str) -> Result { + parse_magnet_fields(input).map(|fields| MagnetUriModel { + info_hash: fields.info_hash, + display_name: fields.display_name, + trackers: fields.trackers, + web_seeds: fields.web_seeds, + exact_topic: fields + .keyword_topic + .or_else(|| fields.peer_hints.last().cloned()), + }) +} + +/// Parses a magnet URI into a richer bootstrap model for live BT orchestration. +/// +/// # Errors +/// +/// Returns an error when the magnet URI is malformed or the BTIH / peer hints are invalid. +pub fn parse_magnet_bootstrap(input: &str) -> Result { + let fields = parse_magnet_fields(input)?; + let uri = MagnetUriModel { + info_hash: fields.info_hash, + display_name: fields.display_name, + trackers: fields.trackers, + web_seeds: fields.web_seeds, + exact_topic: fields + .keyword_topic + .or_else(|| fields.peer_hints.last().cloned()), + }; + let mut bootstrap = uri.bootstrap()?; + let peer_hints = fields + .peer_hints + .iter() + .map(|raw| { + TorrentPeerModel::from_endpoint(raw).map_err(|reason| BtMetalinkError::InvalidMagnet { + reason: format!("invalid x.pe peer hint {raw:?}: {reason}"), + }) + }) + .collect::, _>>()?; + let peer_hint_nodes = peer_hints + .iter() + .map(TorrentPeerModel::to_dht_node) + .collect::>(); + bootstrap.peer_hints = peer_hints; + bootstrap.peer_hint_nodes = peer_hint_nodes; + Ok(bootstrap) +} + +/// Decodes a magnet query fragment using percent-decoding plus `+` as space. +fn percent_decode(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hi = bytes[index + 1]; + let lo = bytes[index + 2]; + if let (Some(hi), Some(lo)) = (hex_value(hi), hex_value(lo)) { + output.push(char::from((hi << 4) | lo)); + index += 3; + continue; + } + } + if bytes[index] == b'+' { + output.push(' '); + index += 1; + continue; + } + output.push(char::from(bytes[index])); + index += 1; + } + output +} + +/// Percent-encodes one magnet query value while preserving URL-safe delimiters. +fn percent_encode_query_value(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' | b'/' => { + output.push(char::from(byte)); + } + b' ' => output.push_str("%20"), + _ => { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + } + output +} + +/// Uppercase hexadecimal digits used by the percent encoder. +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +/// Converts one ASCII hex digit into its numeric nibble value. +const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Parsed magnet query fields before they are shaped into public models. +struct MagnetParseFields { + /// Raw `btih` token captured from the `xt=` field. + info_hash: String, + /// Optional display name decoded from `dn=`. + display_name: Option, + /// Tracker URLs collected from repeated `tr=` fields. + trackers: Vec, + /// Web-seed URLs collected from repeated `ws=` fields. + web_seeds: Vec, + /// Optional keyword topic decoded from `kt=`. + keyword_topic: Option, + /// Peer bootstrap hints collected from repeated `x.pe=` fields. + peer_hints: Vec, +} + +/// Parses raw magnet query fields while preserving repeated `x.pe=` entries. +fn parse_magnet_fields(input: &str) -> Result { + let payload = input + .strip_prefix("magnet:?") + .ok_or_else(|| BtMetalinkError::InvalidMagnet { + reason: "missing magnet:? prefix".to_owned(), + })?; + + let mut info_hash = None; + let mut display_name = None; + let mut trackers = Vec::new(); + let mut web_seeds = Vec::new(); + let mut keyword_topic = None; + let mut peer_hints = Vec::new(); + + for pair in payload.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + match key { + "xt" if value.starts_with("urn:btih:") => { + info_hash = Some(value.trim_start_matches("urn:btih:").to_owned()); + } + "dn" => display_name = Some(percent_decode(value)), + "tr" => trackers.push(percent_decode(value)), + "ws" => web_seeds.push(percent_decode(value)), + "kt" => keyword_topic = Some(percent_decode(value)), + "x.pe" => peer_hints.push(percent_decode(value)), + _ => {} + } + } + + let info_hash = info_hash.ok_or_else(|| BtMetalinkError::InvalidMagnet { + reason: "missing xt=urn:btih:".to_owned(), + })?; + + Ok(MagnetParseFields { + info_hash, + display_name, + trackers, + web_seeds, + keyword_topic, + peer_hints, + }) +} + +/// Decodes one BTIH token into its 20-byte info-hash form. +fn decode_btih_token(input: &str) -> Result<[u8; 20], BtMetalinkError> { + let normalized = input + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::(); + if normalized.len() == 40 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) { + let mut out = [0_u8; 20]; + for (index, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() { + let hi = hex_value(chunk[0]).ok_or_else(|| BtMetalinkError::InvalidMagnet { + reason: format!("invalid hex digit in btih token: {input}"), + })?; + let lo = hex_value(chunk[1]).ok_or_else(|| BtMetalinkError::InvalidMagnet { + reason: format!("invalid hex digit in btih token: {input}"), + })?; + out[index] = (hi << 4) | lo; + } + return Ok(out); + } + if normalized.len() == 32 { + return decode_base32_btih(&normalized); + } + Err(BtMetalinkError::InvalidMagnet { + reason: format!("btih token must be 40 hex or 32 base32 characters, got {input}"), + }) +} + +/// Decodes an RFC 4648 base32 BTIH token into raw bytes. +fn decode_base32_btih(input: &str) -> Result<[u8; 20], BtMetalinkError> { + let mut out = [0_u8; 20]; + let mut accumulator = 0_u64; + let mut bits = 0_u32; + let mut written = 0_usize; + + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a', + b'2'..=b'7' => byte - b'2' + 26, + _ => { + return Err(BtMetalinkError::InvalidMagnet { + reason: format!("invalid base32 digit in btih token: {input}"), + }); + } + }; + accumulator = (accumulator << 5) | u64::from(value); + bits += 5; + while bits >= 8 { + bits -= 8; + if written >= out.len() { + return Err(BtMetalinkError::InvalidMagnet { + reason: format!("base32 btih token decoded longer than 20 bytes: {input}"), + }); + } + out[written] = u8::try_from((accumulator >> bits) & 0xff) + .expect("masked base32 byte must fit into u8"); + written += 1; + } + } + + if written != out.len() { + return Err(BtMetalinkError::InvalidMagnet { + reason: format!("base32 btih token decoded to {written} bytes instead of 20"), + }); + } + Ok(out) +} + +/// Encodes bytes as lowercase hexadecimal text. +fn hex_encode_lower(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(nibble_to_hex(byte >> 4)); + out.push(nibble_to_hex(byte & 0x0f)); + } + out +} + +/// Formats one nibble as a lowercase hexadecimal digit. +fn nibble_to_hex(nibble: u8) -> char { + match nibble { + 0..=9 => char::from(b'0' + nibble), + 10..=15 => char::from(b'a' + (nibble - 10)), + _ => '?', + } +} + +#[cfg(test)] +mod tests { + use super::{MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap, parse_magnet_uri}; + + #[test] + fn parses_magnet_uri_into_model() { + let uri = parse_magnet_uri( + "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", + ) + .expect("magnet uri should parse"); + + assert_eq!(uri.info_hash, "0123456789abcdef"); + assert_eq!(uri.display_name.as_deref(), Some("Ubuntu 24.04")); + assert_eq!(uri.trackers.len(), 1); + assert_eq!(uri.web_seeds.len(), 1); + } + + #[test] + fn model_constructor_tracks_torrent_count() { + let model = MagnetUriModel { + info_hash: "deadbeef".to_owned(), + display_name: None, + trackers: vec!["http://tracker.example.org/announce".to_owned()], + web_seeds: Vec::new(), + exact_topic: Some("urn:btih:deadbeef".to_owned()), + }; + + assert_eq!(model.tracker_count(), 1); + assert_eq!( + model.to_uri(), + "magnet:?xt=urn:btih:deadbeef&tr=http://tracker.example.org/announce&kt=urn:btih:deadbeef" + ); + } + + #[test] + fn magnet_metadata_wraps_uri() { + let uri = MagnetUriModel { + info_hash: "0123456789abcdef".to_owned(), + display_name: Some("Ubuntu".to_owned()), + trackers: Vec::new(), + web_seeds: Vec::new(), + exact_topic: None, + }; + let metadata = MagnetMetadataModel::from_uri(uri.clone(), Some(123)); + + assert_eq!(metadata.uri, uri); + assert_eq!(metadata.known_length, Some(123)); + assert_eq!( + metadata.to_uri(), + "magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu" + ); + } + + #[test] + fn magnet_metadata_can_apply_known_length_from_extended_handshake() { + let uri = MagnetUriModel { + info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), + display_name: Some("Ubuntu".to_owned()), + trackers: vec!["udp://tracker.example.org:6969".to_owned()], + web_seeds: Vec::new(), + exact_topic: None, + }; + let mut metadata = MagnetMetadataModel::from_uri(uri, None); + let handshake = crate::torrent::PeerWireExtensionHandshakeModel { + extensions: std::collections::BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]), + client_name: Some("aria2-rust-pro".to_owned()), + metadata_size: Some(48_321), + request_queue: Some(64), + }; + + metadata.apply_extension_handshake(&handshake); + assert_eq!(metadata.known_length, Some(48_321)); + } + + #[test] + fn parses_realistic_uri_with_multiple_trackers_and_keyword_topic() { + let text = "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567&dn=Arch+Linux+ISO&tr=udp%3A%2F%2Ftracker.one.example%3A1337%2Fannounce&tr=https%3A%2F%2Ftracker.two.example%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Farch.iso&kt=linux+iso"; + let model = parse_magnet_uri(text).expect("realistic magnet should parse"); + + assert_eq!(model.info_hash, "0123456789ABCDEF0123456789ABCDEF01234567"); + assert_eq!(model.display_name.as_deref(), Some("Arch Linux ISO")); + assert_eq!( + model.trackers, + vec![ + "udp://tracker.one.example:1337/announce".to_owned(), + "https://tracker.two.example/announce".to_owned(), + ] + ); + assert_eq!(model.web_seeds, vec!["https://cdn.example.org/arch.iso"]); + assert_eq!(model.exact_topic.as_deref(), Some("linux iso")); + } + + #[test] + fn parser_accepts_x_pe_and_roundtrips_as_kt() { + let parsed = parse_magnet_uri( + "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=Ubuntu%2026.04&tr=http%3A%2F%2Ft1.example%2Fa&tr=http%3A%2F%2Ft2.example%2Fa&ws=https%3A%2F%2Fseed.example%2Fubuntu.iso&x.pe=ubuntu%20lts", + ) + .expect("x.pe should parse"); + assert_eq!(parsed.exact_topic.as_deref(), Some("ubuntu lts")); + assert_eq!(parsed.trackers.len(), 2); + + let roundtrip = parse_magnet_uri(&parsed.to_uri()).expect("roundtrip should parse"); + assert_eq!(roundtrip, parsed); + } + + #[test] + fn magnet_bootstrap_normalizes_info_hash_and_extracts_peer_hints() { + let bootstrap = parse_magnet_bootstrap( + "magnet:?xt=urn:btih:00112233445566778899AABBCCDDEEFF00112233&dn=magnet-bootstrap.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413&x.pe=%5B2001%3Adb8%3A%3A9%5D%3A51413", + ) + .expect("bootstrap magnet should parse"); + + assert_eq!( + bootstrap.info_hash_hex, + "00112233445566778899aabbccddeeff00112233" + ); + assert_eq!(bootstrap.info_hash_bytes[0], 0x00); + assert_eq!(bootstrap.info_hash_bytes[19], 0x33); + assert_eq!(bootstrap.trackers.len(), 2); + assert_eq!(bootstrap.trackers[0].tier, Some(0)); + assert_eq!(bootstrap.trackers[1].tier, Some(1)); + assert_eq!(bootstrap.peer_hints.len(), 2); + assert_eq!(bootstrap.peer_hints[0].ip, "198.51.100.9"); + assert_eq!(bootstrap.peer_hints[0].port, 51413); + assert_eq!(bootstrap.peer_hints[1].ip, "2001:db8::9"); + assert_eq!(bootstrap.peer_hints[1].port, 51413); + assert_eq!(bootstrap.peer_hint_nodes[0].to_spec(), "198.51.100.9:51413"); + assert_eq!( + bootstrap.peer_hint_nodes[1].to_spec(), + "[2001:db8::9]:51413" + ); + } + + #[test] + fn magnet_info_hash_helper_decodes_base32_btih() { + let parsed = + parse_magnet_uri("magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=base32.iso") + .expect("base32 magnet should parse"); + + assert_eq!( + parsed + .canonical_info_hash_hex() + .expect("base32 btih should normalize"), + "0123456789abcdef0123456789abcdef01234567" + ); + assert_eq!( + parsed.info_hash_bytes().expect("base32 btih should decode"), + [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, + ] + ); + } +} diff --git a/crates/aria2-rust-pro-protocol/src/metalink.rs b/crates/aria2-rust-pro-protocol/src/metalink.rs new file mode 100644 index 0000000..d3b15b4 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink.rs @@ -0,0 +1,23 @@ +//! Metalink document parsing and download-candidate selection helpers. +#![forbid(unsafe_code)] + +/// Shared Metalink document and resource data models. +mod model; +/// URL and metadata normalization helpers for parsed Metalink files. +mod normalization; +/// XML parsing entry points for Metalink documents. +mod parser; +/// Download-candidate planning and ranking helpers. +mod planner; + +#[cfg(test)] +mod tests; + +pub use self::model::{ + MetalinkChecksumModel, MetalinkDocumentModel, MetalinkDownloadPlanEntry, MetalinkFileModel, + MetalinkParseResult, MetalinkParserModel, MetalinkResourceModel, +}; +pub use self::parser::parse_metalink_document; +pub use self::planner::{ + metalink_download_plan, preferred_download_candidate, preferred_resource_for_file, +}; diff --git a/crates/aria2-rust-pro-protocol/src/metalink/model.rs b/crates/aria2-rust-pro-protocol/src/metalink/model.rs new file mode 100644 index 0000000..7a1d11e --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink/model.rs @@ -0,0 +1,137 @@ +use crate::http::ChecksumSpec; + +use super::parser::parse_metalink_document; + +/// Checksum entry parsed from a Metalink file description. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkChecksumModel { + /// Checksum algorithm name normalized for downstream use. + pub algorithm: String, + /// Expected checksum value as provided by the document. + pub value: String, + /// Whether the checksum has already been verified by another stage. + pub verified: bool, +} + +/// Mirror or source URI candidate parsed from a Metalink file description. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkResourceModel { + /// Download URI. + pub url: String, + /// Optional location hint such as a country or region code. + pub location: Option, + /// Optional mirror priority where lower values are preferred. + pub priority: Option, + /// Optional maximum per-resource connection count. + pub max_connections: Option, + /// Whether the resource is marked private. + pub private: bool, + /// Optional resource type hint such as `http` or `ftp`. + pub type_hint: Option, + /// Optional language hint. + pub language: Option, +} + +/// File entry parsed from a Metalink document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkFileModel { + /// Output filename suggested by the document. + pub name: String, + /// Optional declared file size in bytes. + pub size: Option, + /// Checksums associated with the file. + pub checksums: Vec, + /// Candidate download resources for the file. + pub resources: Vec, + /// Detached signature payloads or references. + pub signatures: Vec, + /// Optional identity field scoped to this file. + pub identifier: Option, + /// Optional human-readable description. + pub description: Option, +} + +/// Parsed Metalink document model. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkDocumentModel { + /// Metalink version attribute from the root element. + pub version: Option, + /// Files declared by the document. + pub files: Vec, + /// Optional document-wide identity. + pub identity: Option, + /// Optional publisher string. + pub publisher: Option, + /// Optional generator string. + pub generator: Option, + /// Optional publication timestamp. + pub published_at: Option, +} + +/// Parser settings and last-known error state for Metalink parsing. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkParserModel { + /// Whether downstream callers expect strict validation. + pub strict: bool, + /// Last parse error captured by the parser facade. + pub last_error: Option, + /// Whether partial models may be accepted by callers. + pub allow_partial: bool, +} + +/// Result wrapper returned by the parser facade. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkParseResult { + /// Parsed document when successful. + pub document: Option, + /// Parser state after the attempted parse. + pub parser: MetalinkParserModel, +} + +/// Single-file download plan derived from a Metalink document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetalinkDownloadPlanEntry { + /// Target file name. + pub file_name: String, + /// Optional declared size in bytes. + pub size: Option, + /// Preferred checksum supported by the protocol layer. + pub checksum: Option, + /// Ordered list of candidate URIs. + pub uris: Vec, + /// Optional identity field carried into the plan. + pub identifier: Option, + /// Optional description carried into the plan. + pub description: Option, +} + +impl MetalinkParserModel { + /// Creates a parser facade with the requested strictness settings. + #[must_use] + pub const fn new(strict: bool, allow_partial: bool) -> Self { + Self { + strict, + last_error: None, + allow_partial, + } + } + + /// Parses a Metalink document while capturing the last parse error on failure. + #[must_use] + pub fn parse(&self, input: &str) -> MetalinkParseResult { + let mut parser = self.clone(); + match parse_metalink_document(input) { + Ok(document) => MetalinkParseResult { + document: Some(document), + parser, + }, + Err(error) => { + parser.last_error = Some(error); + MetalinkParseResult { + document: None, + parser, + } + } + } + } +} diff --git a/crates/aria2-rust-pro-protocol/src/metalink/normalization.rs b/crates/aria2-rust-pro-protocol/src/metalink/normalization.rs new file mode 100644 index 0000000..92fa7fe --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink/normalization.rs @@ -0,0 +1,96 @@ +/// Decodes one XML local-name byte slice into owned UTF-8-lossy text. +pub(super) fn decode_local_name(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Decodes arbitrary XML text bytes into owned UTF-8-lossy text. +pub(super) fn decode_bytes(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Trims surrounding whitespace from parser text content. +pub(super) fn normalize_text(value: &str) -> String { + value.trim().to_owned() +} + +/// Normalizes an optional resource location hint. +pub(super) fn normalize_location(value: &str) -> Option { + let value = normalize_text(value); + if value.is_empty() { + None + } else { + Some(value.to_ascii_lowercase()) + } +} + +/// Normalizes an optional resource language hint. +pub(super) fn normalize_language(value: &str) -> Option { + let value = normalize_text(value); + if value.is_empty() { + None + } else { + Some(value.to_ascii_lowercase()) + } +} + +/// Normalizes an optional explicit resource type hint. +pub(super) fn normalize_resource_type(value: &str) -> Option { + let value = normalize_text(value); + if value.is_empty() { + None + } else { + Some(value.to_ascii_lowercase()) + } +} + +/// Normalizes Metalink checksum algorithm names to stable downstream forms. +pub(super) fn normalize_checksum_algorithm(value: &str) -> Option { + let normalized = normalize_text(value) + .replace(['_', ' '], "") + .to_ascii_lowercase(); + match normalized.as_str() { + "" => None, + "sha1" => Some("sha-1".to_owned()), + "sha256" => Some("sha-256".to_owned()), + "sha512" => Some("sha-512".to_owned()), + other if other.starts_with("sha-") => Some(other.to_owned()), + other => Some(other.to_owned()), + } +} + +/// Removes separators and lowercases a checksum payload. +pub(super) fn normalize_checksum_value(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase() +} + +/// Infers a resource type hint from a resource URL scheme. +pub(super) fn infer_resource_type_from_url(url: &str) -> Option { + let scheme = url.split(':').next()?.trim(); + if scheme.is_empty() { + None + } else { + Some(scheme.to_ascii_lowercase()) + } +} + +/// Returns whether a Metalink boolean attribute should be treated as enabled. +pub(super) fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) +} + +/// Returns whether the current XML element stack contains `target`. +pub(super) fn stack_contains(stack: &[String], target: &str) -> bool { + stack.iter().any(|entry| entry == target) +} + +/// Returns whether an optional string is absent or only whitespace. +pub(super) fn is_blank_opt(value: Option<&str>) -> bool { + value.is_none_or(str::is_empty) +} diff --git a/crates/aria2-rust-pro-protocol/src/metalink/parser.rs b/crates/aria2-rust-pro-protocol/src/metalink/parser.rs new file mode 100644 index 0000000..f6f47b4 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink/parser.rs @@ -0,0 +1,310 @@ +use quick_xml::{ + Reader, + events::{BytesEnd, BytesStart, Event}, +}; + +use super::{ + model::{ + MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkResourceModel, + }, + normalization::{ + decode_bytes, decode_local_name, infer_resource_type_from_url, is_truthy, + normalize_checksum_algorithm, normalize_checksum_value, normalize_language, + normalize_location, normalize_resource_type, normalize_text, stack_contains, + }, +}; + +/// Parses a Metalink XML document into the protocol-layer model. +/// +/// # Errors +/// +/// Returns an error when the XML is malformed or the document contains no +/// actionable file/resource entries. +pub fn parse_metalink_document(input: &str) -> Result { + let mut reader = Reader::from_str(input); + reader.config_mut().trim_text(true); + + let mut document = MetalinkDocumentModel { + version: None, + files: Vec::new(), + identity: None, + publisher: None, + generator: None, + published_at: None, + }; + + let mut current_file: Option = None; + let mut current_checksum_algorithm: Option = None; + let mut element_stack = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(event)) => handle_start_event( + &event, + &mut document, + &mut current_file, + &mut current_checksum_algorithm, + &mut element_stack, + ), + Ok(Event::Text(text)) => { + let raw_value = text + .decode() + .map_err(|error| error.to_string())? + .into_owned(); + apply_parser_text( + &raw_value, + &element_stack, + &mut document, + &mut current_file, + current_checksum_algorithm.as_deref(), + ); + } + Ok(Event::CData(text)) => { + let raw_value = decode_bytes(text.as_ref()); + apply_parser_text( + &raw_value, + &element_stack, + &mut document, + &mut current_file, + current_checksum_algorithm.as_deref(), + ); + } + Ok(Event::End(event)) => handle_end_event( + &event, + &mut document, + &mut current_file, + &mut current_checksum_algorithm, + &mut element_stack, + ), + Ok(Event::Eof) => break, + Err(error) => return Err(error.to_string()), + _ => {} + } + } + + validate_document(document) +} + +/// Applies one XML start event to the in-progress Metalink parse state. +fn handle_start_event( + event: &BytesStart<'_>, + document: &mut MetalinkDocumentModel, + current_file: &mut Option, + current_checksum_algorithm: &mut Option, + element_stack: &mut Vec, +) { + let name = decode_local_name(event.local_name().as_ref()); + match name.as_str() { + "metalink" => { + document.version = event + .attributes() + .flatten() + .find(|attr| attr.key.local_name().as_ref() == b"version") + .map(|attr| normalize_text(&decode_bytes(attr.value.as_ref()))); + } + "file" => { + *current_file = Some(MetalinkFileModel { + name: file_name_from_start(event), + size: None, + checksums: Vec::new(), + resources: Vec::new(), + signatures: Vec::new(), + identifier: None, + description: None, + }); + } + "url" => push_resource_placeholder(event, current_file), + "hash" => { + *current_checksum_algorithm = checksum_algorithm_from_start(event, element_stack); + } + _ => {} + } + element_stack.push(name); +} + +/// Extracts and normalizes the `name` attribute from a `` start tag. +fn file_name_from_start(event: &BytesStart<'_>) -> String { + event + .attributes() + .flatten() + .find(|attr| attr.key.local_name().as_ref() == b"name") + .map(|attr| normalize_text(&decode_bytes(attr.value.as_ref()))) + .unwrap_or_default() +} + +/// Appends a resource placeholder to the current file when a `` tag begins. +fn push_resource_placeholder(event: &BytesStart<'_>, current_file: &mut Option) { + if let Some(file) = current_file.as_mut() { + file.resources.push(resource_from_start(event)); + } +} + +/// Builds a resource model from one `` start tag and its attributes. +fn resource_from_start(event: &BytesStart<'_>) -> MetalinkResourceModel { + let mut location = None; + let mut priority = None; + let mut max_connections = None; + let mut private = false; + let mut type_hint = None; + let mut language = None; + for attr in event.attributes().flatten() { + let key = decode_local_name(attr.key.local_name().as_ref()); + let value = normalize_text(&decode_bytes(attr.value.as_ref())); + match key.as_str() { + "location" => location = normalize_location(&value), + "priority" => priority = value.parse().ok(), + "maxconnections" => max_connections = value.parse().ok(), + "private" => private = is_truthy(&value), + "type" => type_hint = normalize_resource_type(&value), + "lang" => language = normalize_language(&value), + _ => {} + } + } + MetalinkResourceModel { + url: String::new(), + location, + priority, + max_connections, + private, + type_hint, + language, + } +} + +/// Captures the checksum algorithm for a `` tag outside ``. +fn checksum_algorithm_from_start( + event: &BytesStart<'_>, + element_stack: &[String], +) -> Option { + if stack_contains(element_stack, "pieces") { + return None; + } + event + .attributes() + .flatten() + .find(|attr| attr.key.local_name().as_ref() == b"type") + .and_then(|attr| normalize_checksum_algorithm(&decode_bytes(attr.value.as_ref()))) +} + +/// Routes decoded XML text into the shared text-value application helper. +fn apply_parser_text( + raw_value: &str, + element_stack: &[String], + document: &mut MetalinkDocumentModel, + current_file: &mut Option, + current_checksum_algorithm: Option<&str>, +) { + apply_text_value( + raw_value, + element_stack, + document, + current_file, + current_checksum_algorithm, + ); +} + +/// Applies one XML end event to the in-progress Metalink parse state. +fn handle_end_event( + event: &BytesEnd<'_>, + document: &mut MetalinkDocumentModel, + current_file: &mut Option, + current_checksum_algorithm: &mut Option, + element_stack: &mut Vec, +) { + let name = decode_local_name(event.local_name().as_ref()); + if name == "file" + && let Some(file) = current_file.take() + { + document.files.push(file); + } + if name == "hash" { + *current_checksum_algorithm = None; + } + let _ = element_stack.pop(); +} + +/// Rejects parsed documents that contain no actionable file or resource entries. +fn validate_document(document: MetalinkDocumentModel) -> Result { + if document.files.is_empty() { + return Err("metalink document contains no files".to_owned()); + } + if document.files.iter().all(file_has_no_resource_urls) { + return Err("metalink document contains no resource urls".to_owned()); + } + + Ok(document) +} + +/// Returns whether a file has no non-empty resource URLs. +fn file_has_no_resource_urls(file: &MetalinkFileModel) -> bool { + file.resources.is_empty() + || file + .resources + .iter() + .all(|resource| resource.url.is_empty()) +} + +/// Applies normalized text content to the current document or file context. +fn apply_text_value( + raw_value: &str, + element_stack: &[String], + document: &mut MetalinkDocumentModel, + current_file: &mut Option, + current_checksum_algorithm: Option<&str>, +) { + let value = normalize_text(raw_value); + if value.is_empty() { + return; + } + + let current_element = element_stack.last().map_or("", String::as_str); + match current_element { + "identity" => { + if let Some(file) = current_file.as_mut() { + file.identifier = Some(value); + } else { + document.identity = Some(value); + } + } + "publisher" => document.publisher = Some(value), + "generator" => document.generator = Some(value), + "published" => document.published_at = Some(value), + "size" => { + if let Some(file) = current_file.as_mut() { + file.size = value.parse().ok(); + } + } + "url" => { + if let Some(file) = current_file.as_mut() + && let Some(resource) = file.resources.last_mut() + { + resource.url = value; + if resource.type_hint.is_none() { + resource.type_hint = infer_resource_type_from_url(&resource.url); + } + } + } + "hash" => { + if !stack_contains(element_stack, "pieces") + && let Some(file) = current_file.as_mut() + { + file.checksums.push(MetalinkChecksumModel { + algorithm: current_checksum_algorithm.unwrap_or("unknown").to_owned(), + value: normalize_checksum_value(&value), + verified: false, + }); + } + } + "description" => { + if let Some(file) = current_file.as_mut() { + file.description = Some(value); + } + } + "signature" => { + if let Some(file) = current_file.as_mut() { + file.signatures.push(value); + } + } + _ => {} + } +} diff --git a/crates/aria2-rust-pro-protocol/src/metalink/planner.rs b/crates/aria2-rust-pro-protocol/src/metalink/planner.rs new file mode 100644 index 0000000..0f64e18 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink/planner.rs @@ -0,0 +1,114 @@ +use std::cmp::Reverse; + +use crate::http::ChecksumSpec; + +use super::{ + model::{ + MetalinkDocumentModel, MetalinkDownloadPlanEntry, MetalinkFileModel, MetalinkResourceModel, + }, + normalization::is_blank_opt, +}; + +/// Returns the preferred resource for a file according to priority and richness hints. +#[must_use] +pub fn preferred_resource_for_file(file: &MetalinkFileModel) -> Option<&MetalinkResourceModel> { + file.resources + .iter() + .enumerate() + .filter(|(_, resource)| !resource.url.trim().is_empty()) + .min_by_key(|(index, resource)| { + ( + resource.priority.unwrap_or(u32::MAX), + resource.private, + Reverse(resource.max_connections.unwrap_or(0)), + is_blank_opt(resource.location.as_deref()), + is_blank_opt(resource.type_hint.as_deref()), + is_blank_opt(resource.language.as_deref()), + *index, + ) + }) + .map(|(_, resource)| resource) +} + +/// Returns the first file/resource pair that can be downloaded from the document. +#[must_use] +pub fn preferred_download_candidate( + document: &MetalinkDocumentModel, +) -> Option<(&MetalinkFileModel, &MetalinkResourceModel)> { + document + .files + .iter() + .find_map(|file| preferred_resource_for_file(file).map(|resource| (file, resource))) +} + +/// Builds a per-file download plan with ordered fallback URIs. +#[must_use] +pub fn metalink_download_plan(document: &MetalinkDocumentModel) -> Vec { + document + .files + .iter() + .filter_map(metalink_download_plan_entry_for_file) + .collect() +} + +/// Builds one plan entry for a file when at least one actionable URI exists. +fn metalink_download_plan_entry_for_file( + file: &MetalinkFileModel, +) -> Option { + let preferred = preferred_resource_for_file(file)?; + let mut uris = Vec::new(); + push_unique_uri(&mut uris, &preferred.url); + for resource in &file.resources { + push_unique_uri(&mut uris, &resource.url); + } + if uris.is_empty() { + return None; + } + + Some(MetalinkDownloadPlanEntry { + file_name: file.name.clone(), + size: file.size, + checksum: file_supported_checksum(file), + uris, + identifier: file.identifier.clone(), + description: file.description.clone(), + }) +} + +/// Adds a trimmed URI candidate once while preserving first-seen order. +fn push_unique_uri(uris: &mut Vec, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() || uris.iter().any(|existing| existing == trimmed) { + return; + } + uris.push(trimmed.to_owned()); +} + +/// Selects the first checksum whose algorithm is supported by the protocol layer. +fn file_supported_checksum(file: &MetalinkFileModel) -> Option { + file.checksums.iter().find_map(|checksum| { + supported_checksum_algorithm(&checksum.algorithm).then(|| ChecksumSpec { + algorithm: checksum.algorithm.clone(), + expected_hex: checksum.value.clone(), + actual_hex: None, + }) + }) +} + +/// Returns whether a normalized checksum algorithm is supported downstream. +fn supported_checksum_algorithm(algorithm: &str) -> bool { + matches!( + algorithm, + "sha" + | "sha-1" + | "sha-224" + | "sha-256" + | "sha-384" + | "sha-512" + | "md5" + | "adler32" + | "adler-32" + | "crc32" + | "crc-32" + ) +} diff --git a/crates/aria2-rust-pro-protocol/src/metalink/tests.rs b/crates/aria2-rust-pro-protocol/src/metalink/tests.rs new file mode 100644 index 0000000..cc52982 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/metalink/tests.rs @@ -0,0 +1,448 @@ +use super::{ + MetalinkDocumentModel, MetalinkFileModel, MetalinkParserModel, MetalinkResourceModel, + metalink_download_plan, parse_metalink_document, preferred_download_candidate, + preferred_resource_for_file, +}; + +#[test] +fn parse_metalink4_fixture_matches_golden_document() { + let document = parse_metalink_document( + r#" + + Aria2 reference + aria2 project + golden-fixture + 2026-05-26 + + 1024 + Ubuntu desktop image + aaaaaaaa + https://mirror.jp/ubuntu.iso + https://mirror.us/ubuntu.iso + + + 12 + + +"#, + ) + .expect("metalink should parse"); + + assert_eq!(document.version.as_deref(), Some("4.0")); + assert_eq!(document.identity.as_deref(), Some("Aria2 reference")); + assert_eq!(document.publisher.as_deref(), Some("aria2 project")); + assert_eq!(document.generator.as_deref(), Some("golden-fixture")); + assert_eq!(document.published_at.as_deref(), Some("2026-05-26")); + assert_eq!(document.files.len(), 2); + assert_eq!(document.files[0].name, "ubuntu.iso"); + assert_eq!(document.files[0].size, Some(1024)); + assert_eq!( + document.files[0].description.as_deref(), + Some("Ubuntu desktop image") + ); + assert_eq!(document.files[0].checksums[0].algorithm, "sha-256"); + assert_eq!(document.files[0].checksums[0].value, "aaaaaaaa"); + assert_eq!( + document.files[0].resources[0].url, + "https://mirror.jp/ubuntu.iso" + ); + assert_eq!( + document.files[0].resources[0].location.as_deref(), + Some("jp") + ); + assert_eq!(document.files[0].resources[0].priority, Some(2)); + assert_eq!( + document.files[0].resources[1].url, + "https://mirror.us/ubuntu.iso" + ); + assert_eq!( + document.files[0].resources[1].location.as_deref(), + Some("us") + ); + assert_eq!(document.files[0].resources[1].priority, Some(1)); + assert_eq!(document.files[1].name, "ignored.txt"); + assert_eq!(document.files[1].size, Some(12)); + assert_eq!(document.files[1].resources.len(), 1); + assert_eq!(document.files[1].resources[0].url, ""); + assert_eq!(document.files[1].resources[0].priority, Some(1)); +} + +#[test] +fn parse_metalink3_fixture_matches_golden_document() { + let document = parse_metalink_document( + r#" + + + + 2048 + 1.0 + en + linux + + bbbbbbbb + + + ftp://mirror.de/archive.iso + http://mirror.us/archive.iso + + Archive package + + +"#, + ) + .expect("metalink should parse"); + + assert_eq!(document.version.as_deref(), Some("3.0")); + assert_eq!(document.files.len(), 1); + assert_eq!(document.files[0].name, "archive.iso"); + assert_eq!(document.files[0].size, Some(2048)); + assert_eq!( + document.files[0].description.as_deref(), + Some("Archive package") + ); + assert_eq!(document.files[0].checksums[0].algorithm, "sha-1"); + assert_eq!(document.files[0].checksums[0].value, "bbbbbbbb"); + assert_eq!( + document.files[0].resources[0].url, + "ftp://mirror.de/archive.iso" + ); + assert_eq!( + document.files[0].resources[0].location.as_deref(), + Some("de") + ); + assert_eq!(document.files[0].resources[0].priority, Some(2)); + assert_eq!(document.files[0].resources[0].max_connections, Some(4)); + assert_eq!( + document.files[0].resources[0].type_hint.as_deref(), + Some("ftp") + ); + assert_eq!( + document.files[0].resources[1].url, + "http://mirror.us/archive.iso" + ); + assert_eq!( + document.files[0].resources[1].location.as_deref(), + Some("us") + ); + assert_eq!(document.files[0].resources[1].priority, Some(1)); + assert_eq!( + document.files[0].resources[1].type_hint.as_deref(), + Some("http") + ); +} + +#[test] +fn parse_metalink_normalizes_file_metadata_and_ignores_piece_hashes() { + let document = parse_metalink_document( + r#" + + Document Identity + + release-2026 + AA BB CC DD + + piece-hash-should-be-ignored + + + + ftp://mirror.example.com/normalized.iso + +"#, + ) + .expect("normalized metalink fixture should parse"); + + assert_eq!(document.identity.as_deref(), Some("Document Identity")); + assert_eq!(document.files.len(), 1); + assert_eq!(document.files[0].name, "normalized.iso"); + assert_eq!( + document.files[0].identifier.as_deref(), + Some("release-2026") + ); + assert_eq!(document.files[0].signatures, vec!["SIG-A".to_owned()]); + assert_eq!(document.files[0].checksums.len(), 1); + assert_eq!(document.files[0].checksums[0].algorithm, "sha-256"); + assert_eq!(document.files[0].checksums[0].value, "aabbccdd"); + assert_eq!( + document.files[0].resources[0].url, + "https://mirror.example.com/normalized.iso" + ); + assert_eq!( + document.files[0].resources[0].location.as_deref(), + Some("us") + ); + assert_eq!( + document.files[0].resources[0].language.as_deref(), + Some("en") + ); + assert_eq!(document.files[0].resources[0].max_connections, Some(8)); + assert!(document.files[0].resources[0].private); + assert_eq!( + document.files[0].resources[0].type_hint.as_deref(), + Some("https") + ); + assert_eq!( + document.files[0].resources[1].type_hint.as_deref(), + Some("ftp") + ); +} + +#[test] +fn parser_model_records_error_for_invalid_document() { + let parser = MetalinkParserModel::new(true, false); + let result = parser.parse(""); + + assert!(result.document.is_none()); + assert!(result.parser.last_error.is_some()); +} + +#[test] +fn preferred_resource_for_file_prefers_lower_priority_then_metadata_tiebreakers() { + let file = MetalinkFileModel { + name: "ubuntu.iso".to_owned(), + size: None, + checksums: Vec::new(), + resources: vec![ + MetalinkResourceModel { + url: "https://mirror.jp/ubuntu.iso".to_owned(), + location: Some("jp".to_owned()), + priority: Some(2), + max_connections: None, + private: false, + type_hint: Some("https".to_owned()), + language: None, + }, + MetalinkResourceModel { + url: "https://mirror.us/ubuntu.iso".to_owned(), + location: Some("us".to_owned()), + priority: Some(1), + max_connections: None, + private: false, + type_hint: Some("https".to_owned()), + language: None, + }, + MetalinkResourceModel { + url: "https://mirror.eu/ubuntu.iso".to_owned(), + location: None, + priority: Some(1), + max_connections: None, + private: false, + type_hint: None, + language: None, + }, + ], + signatures: Vec::new(), + identifier: None, + description: None, + }; + + let selected = preferred_resource_for_file(&file).expect("should pick best resource"); + assert_eq!(selected.url, "https://mirror.us/ubuntu.iso"); +} + +#[test] +fn preferred_resource_for_file_prefers_higher_max_connections_when_priority_ties() { + let file = MetalinkFileModel { + name: "parallel.iso".to_owned(), + size: None, + checksums: Vec::new(), + resources: vec![ + MetalinkResourceModel { + url: "https://mirror-a.example.com/parallel.iso".to_owned(), + location: Some("US".to_owned()), + priority: Some(1), + max_connections: Some(2), + private: false, + type_hint: Some("https".to_owned()), + language: Some("en".to_owned()), + }, + MetalinkResourceModel { + url: "https://mirror-b.example.com/parallel.iso".to_owned(), + location: Some("US".to_owned()), + priority: Some(1), + max_connections: Some(8), + private: false, + type_hint: Some("https".to_owned()), + language: Some("en".to_owned()), + }, + ], + signatures: Vec::new(), + identifier: None, + description: None, + }; + + let selected = + preferred_resource_for_file(&file).expect("should prefer higher max-connections"); + assert_eq!(selected.url, "https://mirror-b.example.com/parallel.iso"); +} + +#[test] +fn preferred_resource_for_file_ignores_empty_urls() { + let file = MetalinkFileModel { + name: "example.iso".to_owned(), + size: None, + checksums: Vec::new(), + resources: vec![ + MetalinkResourceModel { + url: " ".to_owned(), + location: Some("us".to_owned()), + priority: Some(1), + max_connections: None, + private: false, + type_hint: Some("https".to_owned()), + language: None, + }, + MetalinkResourceModel { + url: "https://cdn.example.com/example.iso".to_owned(), + location: None, + priority: Some(2), + max_connections: None, + private: false, + type_hint: None, + language: None, + }, + ], + signatures: Vec::new(), + identifier: None, + description: None, + }; + + let selected = preferred_resource_for_file(&file).expect("should skip empty url"); + assert_eq!(selected.url, "https://cdn.example.com/example.iso"); +} + +#[test] +fn preferred_download_candidate_returns_first_actionable_file_candidate() { + let document = MetalinkDocumentModel { + version: Some("4.0".to_owned()), + files: vec![ + MetalinkFileModel { + name: "ignored.bin".to_owned(), + size: None, + checksums: Vec::new(), + resources: vec![MetalinkResourceModel { + url: String::new(), + location: Some("jp".to_owned()), + priority: Some(1), + max_connections: None, + private: false, + type_hint: Some("https".to_owned()), + language: None, + }], + signatures: Vec::new(), + identifier: None, + description: None, + }, + MetalinkFileModel { + name: "picked.bin".to_owned(), + size: None, + checksums: Vec::new(), + resources: vec![ + MetalinkResourceModel { + url: "https://mirror-b.example.com/picked.bin".to_owned(), + location: None, + priority: Some(1), + max_connections: None, + private: false, + type_hint: Some("https".to_owned()), + language: None, + }, + MetalinkResourceModel { + url: "https://mirror-a.example.com/picked.bin".to_owned(), + location: Some("us".to_owned()), + priority: Some(1), + max_connections: None, + private: false, + type_hint: None, + language: None, + }, + ], + signatures: Vec::new(), + identifier: None, + description: None, + }, + ], + identity: None, + publisher: None, + generator: None, + published_at: None, + }; + + let (file, resource) = + preferred_download_candidate(&document).expect("should find actionable candidate"); + assert_eq!(file.name, "picked.bin"); + assert_eq!(resource.url, "https://mirror-a.example.com/picked.bin"); +} + +#[test] +fn preferred_download_candidate_prefers_first_file_with_actionable_resource_from_fixture() { + let document = parse_metalink_document( + r#" + + + + + + https://mirror.example.com/picked.bin + https://mirror.us.example.com/picked.bin + +"#, + ) + .expect("fixture metalink should parse"); + + let (file, resource) = + preferred_download_candidate(&document).expect("should find actionable candidate"); + + assert_eq!(file.name, "picked.bin"); + assert_eq!(resource.url, "https://mirror.us.example.com/picked.bin"); +} + +#[test] +fn metalink_download_plan_expands_actionable_files_and_preserves_checksum_defaults() { + let document = parse_metalink_document( + r#" + + + 900150983cd24fb0d6963f7d28e17f72 + http://fallback.example.org/alpha.bin + http://mirror.example.org/alpha.bin + + + + + + 3610a686 + https://example.org/beta.bin + https://backup.example.org/beta.bin + +"#, + ) + .expect("fixture should parse"); + + let plan = metalink_download_plan(&document); + assert_eq!(plan.len(), 2); + + assert_eq!(plan[0].file_name, "alpha.bin"); + assert_eq!( + plan[0].uris, + vec![ + "http://mirror.example.org/alpha.bin".to_owned(), + "http://fallback.example.org/alpha.bin".to_owned(), + ] + ); + assert_eq!( + plan[0] + .checksum + .as_ref() + .map(|checksum| checksum.algorithm.as_str()), + Some("md5") + ); + + assert_eq!(plan[1].file_name, "beta.bin"); + assert_eq!( + plan[1] + .checksum + .as_ref() + .map(|checksum| checksum.algorithm.as_str()), + Some("crc32") + ); +} diff --git a/crates/aria2-rust-pro-protocol/src/session.rs b/crates/aria2-rust-pro-protocol/src/session.rs new file mode 100644 index 0000000..5d90146 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/session.rs @@ -0,0 +1,120 @@ +//! Session models shared by protocol-aware download workflows. + +#![forbid(unsafe_code)] + +use crate::{ + auth::AuthCredentialModel, + http::{Cookie, HttpHeader, ProxyConfig, RetryStrategy, TlsConfig}, +}; + +/// Coarse lifecycle state for a transport session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionState { + /// Session exists but has not started work. + Idle, + /// Session is establishing a connection. + Connecting, + /// Session is actively transferring data. + Active, + /// Session is paused. + Paused, + /// Session completed successfully. + Completed, + /// Session ended with a failure. + Failed, +} + +/// Scope at which a session model applies. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionScope { + /// Global client-wide scope. + Global, + /// Protocol-family scope. + Protocol, + /// Single transfer scope. + Transfer, +} + +/// Operational limits attached to a session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SessionLimits { + /// Maximum simultaneous connections. + pub max_connections: u16, + /// Maximum parallel downloads across the session. + pub max_parallel_downloads: u16, + /// Delay before reconnecting after a failure, in milliseconds. + pub reconnect_delay_ms: u64, +} + +/// Preferred protocol ordering within a session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionTransportPreference { + /// Protocol identifier string. + pub protocol: String, + /// Smaller numbers indicate higher preference. + pub priority: u8, + /// Whether this protocol is enabled. + pub enabled: bool, +} + +/// End-user session configuration and live state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionModel { + /// Stable session identifier. + pub session_id: String, + /// Current lifecycle state. + pub state: SessionState, + /// Scope for this session. + pub scope: SessionScope, + /// Optional user-agent string. + pub user_agent: Option, + /// Default headers applied to requests. + pub headers: Vec, + /// Persisted or injected cookies. + pub cookies: Vec, + /// Optional authentication material. + pub auth: Option, + /// Optional proxy configuration. + pub proxy: Option, + /// Optional TLS configuration. + pub tls: Option, + /// Retry behavior for requests in the session. + pub retry: RetryStrategy, + /// Operational session limits. + pub limits: SessionLimits, + /// Ordered transport preferences. + pub preferred_transports: Vec, +} + +/// Remote server descriptor associated with a session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServerModel { + /// Human-readable server name. + pub name: String, + /// Host name or IP address. + pub host: String, + /// Listen port. + pub port: u16, + /// Whether TLS is enabled. + pub tls_enabled: bool, +} + +/// Client descriptor that can be associated with server sessions. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientModel { + /// Stable client identifier. + pub client_id: String, + /// Preferred session id when one exists. + pub preferred_session: Option, + /// Optional server currently associated with the client. + pub server: Option, +} + +/// Collection of sessions known for a given server. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServerSessionModel { + /// Server whose sessions are being reported. + pub server: ServerModel, + /// Sessions currently associated with the server. + pub sessions: Vec, +} diff --git a/crates/aria2-rust-pro-protocol/src/sftp.rs b/crates/aria2-rust-pro-protocol/src/sftp.rs new file mode 100644 index 0000000..a8c1fae --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/sftp.rs @@ -0,0 +1,107 @@ +//! SFTP request, response, and session models. + +#![forbid(unsafe_code)] + +use crate::{ + auth::AuthCredentialModel, + http::{HttpHeader, ProxyConfig, RetryStrategy, TlsConfig}, +}; + +/// Connection, authentication, and retry settings for an SFTP endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SftpConfigModel { + /// Remote host name or IP. + pub host: String, + /// Remote SSH port. + pub port: u16, + /// Optional username for login. + pub username: Option, + /// Optional password for login. + pub password: Option, + /// Optional path to a private key file. + pub private_key_path: Option, + /// Optional known-hosts file path. + pub known_hosts_path: Option, + /// Whether host-key validation is strict. + pub strict_host_key_checking: bool, + /// Optional proxy configuration. + pub proxy: Option, + /// Optional TLS tuning data when the transport stack uses it. + pub tls: Option, + /// Retry strategy for failed requests. + pub retry: RetryStrategy, +} + +/// SFTP command issued within a request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SftpCommandModel { + /// Establish a connection. + Connect, + /// Read metadata for a path. + Stat(String), + /// Read symlink-aware metadata for a path. + Lstat(String), + /// Read a directory listing. + ReadDir(String), + /// Open a remote path. + Open(String), + /// Read a byte range from a remote path. + Read { + /// Target path. + path: String, + /// Starting byte offset. + offset: u64, + /// Maximum number of bytes to read. + length: u64, + }, + /// Close an open handle identified by path or token. + Close(String), + /// Rename a path. + Rename { + /// Source path. + from: String, + /// Destination path. + to: String, + }, + /// Remove a path. + Remove(String), +} + +/// SFTP session state captured by the protocol layer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SftpSessionModel { + /// Stable session identifier. + pub session_id: String, + /// Resolved endpoint configuration. + pub config: SftpConfigModel, + /// Optional authenticated credential. + pub auth: Option, + /// Default headers propagated into requests. + pub default_headers: Vec, +} + +/// SFTP request envelope passed into a connector. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SftpRequestModel { + /// Command to execute. + pub command: SftpCommandModel, + /// Optional primary path associated with the command. + pub path: Option, + /// Additional logical headers attached to the request. + pub headers: Vec, +} + +/// SFTP response material returned by a connector. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SftpResponseModel { + /// Whether the command succeeded. + pub ok: bool, + /// Human-readable status or error message. + pub message: String, + /// Optional payload bytes such as file contents. + pub payload: Option>, + /// Optional path associated with the response. + pub path: Option, + /// Whether the response can carry transferable data. + pub transferable: bool, +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent.rs b/crates/aria2-rust-pro-protocol/src/torrent.rs new file mode 100644 index 0000000..097a577 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent.rs @@ -0,0 +1,39 @@ +//! Torrent metadata, peer-wire framing, and DHT message helpers. +#![forbid(unsafe_code)] + +/// Internal torrent bencode value parsing and encoding helpers. +mod bencode; +/// Distributed-hash-table message models and compact-node helpers. +mod dht; +/// Torrent metadata/bootstrap parsing and derivation helpers. +mod metadata; +/// Parsed torrent models and runtime-adjacent helper methods. +mod model; +/// `BitTorrent` peer-wire framing and metadata-exchange helpers. +mod peer_wire; +/// Shared torrent-local decoding and conversion helpers. +mod utils; + +#[cfg(test)] +use self::dht::compact::{decode_compact_dht_nodes, encode_compact_dht_nodes}; + +pub use self::dht::{ + DhtAnnouncePeerQueryModel, DhtCompactNodeModel, DhtErrorModel, DhtFindNodeQueryModel, + DhtFindNodeResponseModel, DhtGetPeersQueryModel, DhtGetPeersResponseModel, DhtMessageBody, + DhtMessageModel, DhtPingQueryModel, DhtPingResponseModel, DhtQueryModel, DhtResponseModel, +}; +pub use self::metadata::{parse_torrent_bootstrap, parse_torrent_metadata}; +pub use self::model::{ + TorrentBootstrapModel, TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel, + TorrentMetadataModel, TorrentPeerModel, TorrentPieceModel, TorrentTrackerModel, +}; +pub use self::peer_wire::{ + PEER_WIRE_METADATA_PIECE_SIZE, PeerWireBitfieldModel, PeerWireBlockRequestModel, + PeerWireExtensionHandshakeModel, PeerWireExtensionMessageModel, PeerWireFrameHeaderModel, + PeerWireHandshakeModel, PeerWireMessageKind, PeerWireMessageModel, + PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel, + PeerWireUnknownMessageModel, TorrentMessageModel, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/aria2-rust-pro-protocol/src/torrent/bencode.rs b/crates/aria2-rust-pro-protocol/src/torrent/bencode.rs new file mode 100644 index 0000000..9fdb79d --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/bencode.rs @@ -0,0 +1,214 @@ +use std::collections::BTreeMap; + +/// Internal torrent bencode dictionary keyed by normalized string keys. +pub(super) type TorrentBencodeDict = BTreeMap; + +/// Internal bencode value representation used while parsing torrent metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum BencodeValue { + /// Signed integer literal. + Int(i64), + /// Raw byte string payload. + Bytes(Vec), + /// Ordered list of nested bencode values. + List(Vec), + /// Dictionary keyed by normalized torrent strings. + Dict(TorrentBencodeDict), +} + +/// Encodes a torrent-style bencode value into raw bytes. +pub(super) fn encode_bencode_value(value: &BencodeValue, out: &mut Vec) { + match value { + BencodeValue::Int(number) => { + out.push(b'i'); + out.extend_from_slice(number.to_string().as_bytes()); + out.push(b'e'); + } + BencodeValue::Bytes(bytes) => { + out.extend_from_slice(bytes.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(bytes); + } + BencodeValue::List(values) => { + out.push(b'l'); + for value in values { + encode_bencode_value(value, out); + } + out.push(b'e'); + } + BencodeValue::Dict(dict) => encode_bencode_dict(dict, out), + } +} + +/// Encodes a torrent-style bencode dictionary into raw bytes. +pub(super) fn encode_bencode_dict(dict: &TorrentBencodeDict, out: &mut Vec) { + out.push(b'd'); + for (key, value) in dict { + out.extend_from_slice(key.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(key.as_bytes()); + encode_bencode_value(value, out); + } + out.push(b'e'); +} + +/// Encodes a torrent-style bencode dictionary as a root value. +pub(super) fn encode_bencode_root(dict: &TorrentBencodeDict) -> Vec { + let mut out = Vec::new(); + encode_bencode_dict(dict, &mut out); + out +} + +/// Parses the torrent root dictionary and captures the raw `info` dictionary bytes. +pub(super) fn parse_root_dict(input: &[u8]) -> Result<(TorrentBencodeDict, Option<&[u8]>), String> { + if input.first().copied() != Some(b'd') { + return Err("torrent root must be dictionary".to_owned()); + } + + let mut cursor = 1; + let mut map = BTreeMap::new(); + let mut info_raw = None; + + while cursor < input.len() { + if input[cursor] == b'e' { + cursor += 1; + if cursor != input.len() { + return Err("trailing bytes after root dictionary".to_owned()); + } + return Ok((map, info_raw)); + } + + let (key_bytes, next) = parse_bytes(input, cursor)?; + cursor = next; + let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?; + + let value_start = cursor; + let (value, end) = parse_value(input, cursor)?; + if key == "info" && matches!(value, BencodeValue::Dict(_)) { + info_raw = input.get(value_start..end); + } + cursor = end; + map.insert(key, value); + } + + Err("unterminated dictionary".to_owned()) +} + +/// Parses one root bencode dictionary and allows trailing bytes after the dictionary. +pub(super) fn parse_bencode_root_prefix( + input: &[u8], +) -> Result<(TorrentBencodeDict, usize), String> { + let (value, consumed) = parse_value(input, 0)?; + match value { + BencodeValue::Dict(dict) => Ok((dict, consumed)), + _ => Err("bencode root must be a dictionary".to_owned()), + } +} + +/// Parses one complete root bencode dictionary. +pub(super) fn parse_bencode_root_exact(input: &[u8]) -> Result { + let (dict, consumed) = parse_bencode_root_prefix(input)?; + if consumed != input.len() { + return Err("trailing bytes after bencode dictionary".to_owned()); + } + Ok(dict) +} + +/// Parses one torrent bencode value and returns the decoded value plus next index. +fn parse_value(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> { + match input.get(index).copied() { + Some(b'i') => parse_int(input, index), + Some(b'l') => parse_list(input, index), + Some(b'd') => parse_dict(input, index).map(|(map, end)| (BencodeValue::Dict(map), end)), + Some(b'0'..=b'9') => { + parse_bytes(input, index).map(|(bytes, end)| (BencodeValue::Bytes(bytes), end)) + } + _ => Err("invalid bencode value".to_owned()), + } +} + +/// Parses one torrent bencode integer starting at `index`. +fn parse_int(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> { + let mut cursor = index + 1; + while cursor < input.len() && input[cursor] != b'e' { + cursor += 1; + } + if cursor >= input.len() { + return Err("unterminated integer".to_owned()); + } + let number = std::str::from_utf8(&input[index + 1..cursor]) + .map_err(|_| "invalid integer".to_owned())? + .parse::() + .map_err(|_| "invalid integer".to_owned())?; + Ok((BencodeValue::Int(number), cursor + 1)) +} + +/// Parses one torrent bencode list starting at `index`. +fn parse_list(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> { + let mut cursor = index + 1; + let mut values = Vec::new(); + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((BencodeValue::List(values), cursor + 1)); + } + let (value, end) = parse_value(input, cursor)?; + values.push(value); + cursor = end; + } + Err("unterminated list".to_owned()) +} + +/// Parses one torrent bencode dictionary starting at `index`. +fn parse_dict(input: &[u8], index: usize) -> Result<(TorrentBencodeDict, usize), String> { + let mut cursor = index + 1; + let mut map = BTreeMap::new(); + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((map, cursor + 1)); + } + let (key_bytes, next) = parse_bytes(input, cursor)?; + cursor = next; + let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?; + let (value, end) = parse_value(input, cursor)?; + cursor = end; + map.insert(key, value); + } + Err("unterminated dictionary".to_owned()) +} + +/// Parses one torrent bencode byte string starting at `index`. +fn parse_bytes(input: &[u8], index: usize) -> Result<(Vec, usize), String> { + let mut cursor = index; + while cursor < input.len() && input[cursor].is_ascii_digit() { + cursor += 1; + } + if cursor == index || cursor >= input.len() || input[cursor] != b':' { + return Err("invalid bencode byte string".to_owned()); + } + let len = std::str::from_utf8(&input[index..cursor]) + .map_err(|_| "invalid byte string length".to_owned())? + .parse::() + .map_err(|_| "invalid byte string length".to_owned())?; + let start = cursor + 1; + let end = start.saturating_add(len); + if end > input.len() { + return Err("truncated byte string".to_owned()); + } + Ok((input[start..end].to_vec(), end)) +} + +/// Looks up a byte-string field inside a torrent bencode dictionary. +pub(super) fn dict_bytes<'a>(dict: &'a TorrentBencodeDict, key: &str) -> Option<&'a [u8]> { + match dict.get(key) { + Some(BencodeValue::Bytes(bytes)) => Some(bytes.as_slice()), + _ => None, + } +} + +/// Looks up an integer field inside a torrent bencode dictionary. +pub(super) fn dict_int(dict: &TorrentBencodeDict, key: &str) -> Option { + match dict.get(key) { + Some(BencodeValue::Int(value)) => Some(*value), + _ => None, + } +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/dht.rs b/crates/aria2-rust-pro-protocol/src/torrent/dht.rs new file mode 100644 index 0000000..d8b5863 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/dht.rs @@ -0,0 +1,157 @@ +use std::collections::BTreeMap; + +use crate::tracker::DhtNodeModel; + +use super::utils::hex_encode; +/// DHT bencode codec helpers. +mod codec; +/// Compact-node and compact-peer conversion helpers. +pub(super) mod compact; +/// DHT message builders plus wire-format parsing helpers. +mod message; + +/// One DHT message with a transaction id and typed body. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtMessageModel { + /// Opaque DHT transaction id. + pub transaction_id: Vec, + /// Typed message body. + pub body: DhtMessageBody, +} + +/// Supported DHT message body variants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DhtMessageBody { + /// Outbound or inbound query. + Query(DhtQueryModel), + /// Successful response payload. + Response(DhtResponseModel), + /// Error response payload. + Error(DhtErrorModel), +} + +/// Supported DHT query variants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DhtQueryModel { + /// `ping` query. + Ping(DhtPingQueryModel), + /// `find_node` query. + FindNode(DhtFindNodeQueryModel), + /// `get_peers` query. + GetPeers(DhtGetPeersQueryModel), + /// `announce_peer` query. + AnnouncePeer(DhtAnnouncePeerQueryModel), +} + +/// DHT `ping` query payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtPingQueryModel { + /// Querying node id. + pub node_id: Vec, +} + +/// DHT `find_node` query payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtFindNodeQueryModel { + /// Querying node id. + pub node_id: Vec, + /// Target node id being searched. + pub target: Vec, +} + +/// DHT `get_peers` query payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtGetPeersQueryModel { + /// Querying node id. + pub node_id: Vec, + /// Torrent info-hash being searched. + pub info_hash: Vec, +} + +/// DHT `announce_peer` query payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtAnnouncePeerQueryModel { + /// Querying node id. + pub node_id: Vec, + /// Torrent info-hash being announced. + pub info_hash: Vec, + /// Advertised listening port. + pub port: u16, + /// Tracker-issued or routing token. + pub token: Vec, + /// Whether the sender requested implied-port semantics. + pub implied_port: bool, +} + +/// Supported DHT response variants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DhtResponseModel { + /// `ping` or `announce_peer` response payload. + Ping(DhtPingResponseModel), + /// `find_node` response payload. + FindNode(DhtFindNodeResponseModel), + /// `get_peers` response payload. + GetPeers(DhtGetPeersResponseModel), +} + +/// DHT `ping` response payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtPingResponseModel { + /// Responding node id. + pub node_id: Vec, +} + +/// One compact DHT node entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DhtCompactNodeModel { + /// Remote node id. + pub node_id: [u8; 20], + /// IPv4 address bytes. + pub address: [u8; 4], + /// UDP port in host byte order. + pub port: u16, +} + +/// DHT `find_node` response payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtFindNodeResponseModel { + /// Responding node id. + pub node_id: Vec, + /// Returned compact nodes. + pub nodes: Vec, +} + +/// DHT `get_peers` response payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtGetPeersResponseModel { + /// Responding node id. + pub node_id: Vec, + /// Optional token to reuse in `announce_peer`. + pub token: Option>, + /// Optional compact-node blob. + pub nodes: Option>, + /// Optional compact-peer values. + pub values: Vec>, +} + +/// DHT error payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtErrorModel { + /// Numeric error code. + pub code: i64, + /// Human-readable error message. + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +/// Internal bencode value representation used while parsing DHT payloads. +enum DhtBencodeValue { + /// Signed integer literal. + Int(i64), + /// Raw byte string payload. + Bytes(Vec), + /// Ordered list of nested bencode values. + List(Vec), + /// Dictionary keyed by raw byte strings. + Dict(BTreeMap, Self>), +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/dht/codec.rs b/crates/aria2-rust-pro-protocol/src/torrent/dht/codec.rs new file mode 100644 index 0000000..24bee14 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/dht/codec.rs @@ -0,0 +1,177 @@ +use std::collections::BTreeMap; + +use super::DhtBencodeValue; + +/// Encodes one DHT bencode value into a byte buffer. +fn dht_encode_value(value: &DhtBencodeValue, out: &mut Vec) { + match value { + DhtBencodeValue::Int(number) => { + out.push(b'i'); + out.extend_from_slice(number.to_string().as_bytes()); + out.push(b'e'); + } + DhtBencodeValue::Bytes(bytes) => { + out.extend_from_slice(bytes.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(bytes); + } + DhtBencodeValue::List(values) => { + out.push(b'l'); + for item in values { + dht_encode_value(item, out); + } + out.push(b'e'); + } + DhtBencodeValue::Dict(values) => { + out.push(b'd'); + for (key, value) in values { + out.extend_from_slice(key.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(key); + dht_encode_value(value, out); + } + out.push(b'e'); + } + } +} + +/// Encodes a DHT dictionary into canonical bencode bytes. +pub(super) fn dht_encode_dict(dict: &BTreeMap, DhtBencodeValue>) -> Vec { + let mut out = Vec::new(); + dht_encode_value(&DhtBencodeValue::Dict(dict.clone()), &mut out); + out +} + +/// Parses one DHT bencode value and returns the decoded value plus next index. +pub(super) fn dht_parse_value( + input: &[u8], + index: usize, +) -> Result<(DhtBencodeValue, usize), String> { + match input.get(index).copied() { + Some(b'i') => dht_parse_int(input, index + 1), + Some(b'l') => dht_parse_list(input, index + 1), + Some(b'd') => dht_parse_dict(input, index + 1), + Some(byte) if byte.is_ascii_digit() => dht_parse_bytes(input, index), + Some(_) => Err("invalid dht bencode value".to_owned()), + None => Err("unexpected end of dht bencode input".to_owned()), + } +} + +/// Parses one DHT bencode integer starting at `index`. +fn dht_parse_int(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> { + let mut cursor = index; + while cursor < input.len() && input[cursor] != b'e' { + cursor += 1; + } + if cursor >= input.len() { + return Err("unterminated dht integer".to_owned()); + } + let text = std::str::from_utf8(&input[index..cursor]) + .map_err(|_| "invalid dht integer bytes".to_owned())?; + let value = text + .parse::() + .map_err(|_| "invalid dht integer value".to_owned())?; + Ok((DhtBencodeValue::Int(value), cursor + 1)) +} + +/// Parses one DHT bencode list starting at `index`. +fn dht_parse_list(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> { + let mut values = Vec::new(); + let mut cursor = index; + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((DhtBencodeValue::List(values), cursor + 1)); + } + let (value, next) = dht_parse_value(input, cursor)?; + values.push(value); + cursor = next; + } + Err("unterminated dht list".to_owned()) +} + +/// Parses one DHT bencode dictionary starting at `index`. +fn dht_parse_dict(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> { + let mut map = BTreeMap::new(); + let mut cursor = index; + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((DhtBencodeValue::Dict(map), cursor + 1)); + } + let (key, key_end) = dht_parse_bytes_raw(input, cursor)?; + let (value, value_end) = dht_parse_value(input, key_end)?; + map.insert(key, value); + cursor = value_end; + } + Err("unterminated dht dictionary".to_owned()) +} + +/// Parses one DHT bencode byte string starting at `index`. +fn dht_parse_bytes(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> { + let (bytes, next) = dht_parse_bytes_raw(input, index)?; + Ok((DhtBencodeValue::Bytes(bytes), next)) +} + +/// Parses one raw DHT bencode byte string and returns its bytes plus next index. +fn dht_parse_bytes_raw(input: &[u8], index: usize) -> Result<(Vec, usize), String> { + let mut cursor = index; + while cursor < input.len() && input[cursor].is_ascii_digit() { + cursor += 1; + } + if cursor == index || cursor >= input.len() || input[cursor] != b':' { + return Err("invalid dht byte string".to_owned()); + } + let length = std::str::from_utf8(&input[index..cursor]) + .map_err(|_| "invalid dht byte string length".to_owned())? + .parse::() + .map_err(|_| "invalid dht byte string length".to_owned())?; + let start = cursor + 1; + let end = start.saturating_add(length); + if end > input.len() { + return Err("truncated dht byte string".to_owned()); + } + Ok((input[start..end].to_vec(), end)) +} + +/// Looks up a raw byte-string field inside a DHT dictionary. +pub(super) fn dht_dict_get_bytes( + dict: &BTreeMap, DhtBencodeValue>, + key: &[u8], +) -> Option> { + match dict.get(key) { + Some(DhtBencodeValue::Bytes(bytes)) => Some(bytes.clone()), + _ => None, + } +} + +/// Looks up an integer field inside a DHT dictionary. +pub(super) fn dht_dict_get_int( + dict: &BTreeMap, DhtBencodeValue>, + key: &[u8], +) -> Option { + match dict.get(key) { + Some(DhtBencodeValue::Int(value)) => Some(*value), + _ => None, + } +} + +/// Looks up a nested dictionary field inside a DHT dictionary. +pub(super) fn dht_dict_get_dict<'a>( + dict: &'a BTreeMap, DhtBencodeValue>, + key: &[u8], +) -> Option<&'a BTreeMap, DhtBencodeValue>> { + match dict.get(key) { + Some(DhtBencodeValue::Dict(value)) => Some(value), + _ => None, + } +} + +/// Looks up a list field inside a DHT dictionary. +pub(super) fn dht_dict_get_list( + dict: &BTreeMap, DhtBencodeValue>, + key: &[u8], +) -> Option> { + match dict.get(key) { + Some(DhtBencodeValue::List(values)) => Some(values.clone()), + _ => None, + } +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/dht/compact.rs b/crates/aria2-rust-pro-protocol/src/torrent/dht/compact.rs new file mode 100644 index 0000000..f2f2fa9 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/dht/compact.rs @@ -0,0 +1,114 @@ +use super::hex_encode; +use super::{ + DhtCompactNodeModel, DhtFindNodeResponseModel, DhtGetPeersResponseModel, DhtNodeModel, +}; +use crate::torrent::TorrentPeerModel; + +impl DhtCompactNodeModel { + /// Converts the compact node entry into a higher-level DHT node model. + #[must_use] + pub fn to_dht_node(self) -> DhtNodeModel { + DhtNodeModel { + node_id: hex_encode(&self.node_id), + address: format!( + "{}.{}.{}.{}", + self.address[0], self.address[1], self.address[2], self.address[3] + ), + port: self.port, + } + } +} + +impl DhtFindNodeResponseModel { + /// Shapes compact DHT node entries into higher-level node models. + #[must_use] + pub fn dht_nodes(&self) -> Vec { + self.nodes + .iter() + .copied() + .map(DhtCompactNodeModel::to_dht_node) + .collect() + } +} + +impl DhtGetPeersResponseModel { + /// Decodes compact peer-contact payloads into higher-level peer rows. + /// + /// # Errors + /// + /// Returns an error when any compact peer payload is malformed. + pub fn peer_contacts(&self) -> Result, String> { + parse_compact_peer_contacts(&self.values) + } + + /// Decodes the optional compact-node blob into higher-level DHT node rows. + /// + /// # Errors + /// + /// Returns an error when the compact-node blob is malformed. + pub fn dht_nodes(&self) -> Result, String> { + let Some(nodes) = &self.nodes else { + return Ok(Vec::new()); + }; + decode_compact_dht_nodes(nodes).map(|nodes| { + nodes + .into_iter() + .map(DhtCompactNodeModel::to_dht_node) + .collect() + }) + } +} + +/// Encodes compact DHT nodes into the BEP 5 26-byte-per-node representation. +pub(in super::super) fn encode_compact_dht_nodes(nodes: &[DhtCompactNodeModel]) -> Vec { + let mut bytes = Vec::with_capacity(nodes.len() * 26); + for node in nodes { + bytes.extend_from_slice(&node.node_id); + bytes.extend_from_slice(&node.address); + bytes.extend_from_slice(&node.port.to_be_bytes()); + } + bytes +} + +/// Decodes compact DHT nodes from the BEP 5 26-byte-per-node representation. +pub(in super::super) fn decode_compact_dht_nodes( + input: &[u8], +) -> Result, String> { + if !input.len().is_multiple_of(26) { + return Err("compact dht node list length must be a multiple of 26".to_owned()); + } + let mut nodes = Vec::with_capacity(input.len() / 26); + for chunk in input.chunks_exact(26) { + let mut node_id = [0_u8; 20]; + node_id.copy_from_slice(&chunk[..20]); + let mut address = [0_u8; 4]; + address.copy_from_slice(&chunk[20..24]); + nodes.push(DhtCompactNodeModel { + node_id, + address, + port: u16::from_be_bytes([chunk[24], chunk[25]]), + }); + } + Ok(nodes) +} + +/// Decodes compact BEP 5 peer-contact payloads into higher-level peer models. +fn parse_compact_peer_contacts(values: &[Vec]) -> Result, String> { + let mut peers = Vec::new(); + for value in values { + if value.len() % 6 != 0 { + return Err("compact peer list length must be a multiple of 6".to_owned()); + } + for chunk in value.chunks_exact(6) { + peers.push(TorrentPeerModel { + peer_id: None, + ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]), + port: u16::from_be_bytes([chunk[4], chunk[5]]), + client_name: None, + interested: false, + choked: false, + }); + } + } + Ok(peers) +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/dht/message.rs b/crates/aria2-rust-pro-protocol/src/torrent/dht/message.rs new file mode 100644 index 0000000..bfb4b07 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/dht/message.rs @@ -0,0 +1,488 @@ +use super::codec::{ + dht_dict_get_bytes, dht_dict_get_dict, dht_dict_get_int, dht_dict_get_list, dht_encode_dict, + dht_parse_value, +}; +use super::compact::{decode_compact_dht_nodes, encode_compact_dht_nodes}; +use std::collections::BTreeMap; + +use super::{ + DhtAnnouncePeerQueryModel, DhtBencodeValue, DhtCompactNodeModel, DhtErrorModel, + DhtFindNodeQueryModel, DhtFindNodeResponseModel, DhtGetPeersQueryModel, + DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtPingQueryModel, + DhtPingResponseModel, DhtQueryModel, DhtResponseModel, +}; + +impl DhtMessageModel { + #[must_use] + /// Builds a DHT `ping` query. + pub fn ping_query(transaction_id: impl Into>, node_id: impl Into>) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel { + node_id: node_id.into(), + })), + } + } + + #[must_use] + /// Builds a DHT `find_node` query. + pub fn find_node_query( + transaction_id: impl Into>, + node_id: impl Into>, + target: impl Into>, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel { + node_id: node_id.into(), + target: target.into(), + })), + } + } + + #[must_use] + /// Builds a DHT `get_peers` query. + pub fn get_peers_query( + transaction_id: impl Into>, + node_id: impl Into>, + info_hash: impl Into>, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel { + node_id: node_id.into(), + info_hash: info_hash.into(), + })), + } + } + + #[must_use] + /// Builds a DHT `announce_peer` query. + pub fn announce_peer_query( + transaction_id: impl Into>, + node_id: impl Into>, + info_hash: impl Into>, + port: u16, + token: impl Into>, + implied_port: bool, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel { + node_id: node_id.into(), + info_hash: info_hash.into(), + port, + token: token.into(), + implied_port, + })), + } + } + + #[must_use] + /// Builds a DHT `ping` response. + pub fn ping_response(transaction_id: impl Into>, node_id: impl Into>) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel { + node_id: node_id.into(), + })), + } + } + + #[must_use] + /// Builds a DHT `find_node` response. + pub fn find_node_response( + transaction_id: impl Into>, + node_id: impl Into>, + nodes: Vec, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel { + node_id: node_id.into(), + nodes, + })), + } + } + + #[must_use] + /// Builds a DHT `get_peers` response. + pub fn get_peers_response( + transaction_id: impl Into>, + node_id: impl Into>, + token: Option>, + nodes: Option>, + values: Vec>, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel { + node_id: node_id.into(), + token, + nodes, + values, + })), + } + } + + #[must_use] + /// Builds a DHT `announce_peer` response. + pub fn announce_peer_response( + transaction_id: impl Into>, + node_id: impl Into>, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel { + node_id: node_id.into(), + })), + } + } + + #[must_use] + /// Builds a DHT error response. + pub fn error_response( + transaction_id: impl Into>, + code: i64, + message: impl Into, + ) -> Self { + Self { + transaction_id: transaction_id.into(), + body: DhtMessageBody::Error(DhtErrorModel { + code, + message: message.into(), + }), + } + } + + #[must_use] + /// Returns the raw DHT transaction id bytes. + pub fn transaction_id(&self) -> &[u8] { + &self.transaction_id + } + + #[must_use] + /// Returns the DHT query method name when the message body is a query. + pub fn method(&self) -> Option<&'static str> { + match &self.body { + DhtMessageBody::Query(DhtQueryModel::Ping(_)) => Some("ping"), + DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => Some("find_node"), + DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => Some("get_peers"), + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => Some("announce_peer"), + _ => None, + } + } + + #[must_use] + /// Returns whether this message body is a DHT query. + pub fn is_query(&self) -> bool { + matches!(self.body, DhtMessageBody::Query(_)) + } + + #[must_use] + /// Serializes the DHT message as a bencoded root dictionary. + pub fn to_bencode_bytes(&self) -> Vec { + dht_encode_dict(&self.as_bencode_root()) + } + + /// Parses a DHT message from a bencoded payload. + /// + /// # Errors + /// + /// Returns an error when the payload is not a supported DHT message dictionary. + pub fn from_bencode_bytes(input: &[u8]) -> Result { + let (value, next) = dht_parse_value(input, 0)?; + if next != input.len() { + return Err("trailing bytes after dht message".to_owned()); + } + let DhtBencodeValue::Dict(root) = value else { + return Err("dht message must be a bencoded dictionary".to_owned()); + }; + let transaction_id = dht_dict_get_bytes(&root, b"t") + .ok_or_else(|| "missing dht transaction id".to_owned())?; + let message_type = + dht_dict_get_bytes(&root, b"y").ok_or_else(|| "missing dht message type".to_owned())?; + + match message_type.as_slice() { + b"q" => parse_dht_query_message(transaction_id, &root), + b"r" => parse_dht_response_message(transaction_id, &root), + b"e" => parse_dht_error_message(transaction_id, &root), + _ => Err("unsupported dht message type".to_owned()), + } + } + + #[expect( + clippy::too_many_lines, + reason = "torrent roundtrip test keeps the end-to-end fixture in one place for auditability" + )] + /// Rebuilds the DHT message as the bencode root dictionary used on the wire. + fn as_bencode_root(&self) -> BTreeMap, DhtBencodeValue> { + let mut root = BTreeMap::new(); + root.insert( + b"t".to_vec(), + DhtBencodeValue::Bytes(self.transaction_id.clone()), + ); + + match &self.body { + DhtMessageBody::Query(query) => { + root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"q".to_vec())); + match query { + DhtQueryModel::Ping(query) => { + root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"ping".to_vec())); + let mut args = BTreeMap::new(); + args.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(query.node_id.clone()), + ); + root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args)); + } + DhtQueryModel::FindNode(query) => { + root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"find_node".to_vec())); + let mut args = BTreeMap::new(); + args.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(query.node_id.clone()), + ); + args.insert( + b"target".to_vec(), + DhtBencodeValue::Bytes(query.target.clone()), + ); + root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args)); + } + DhtQueryModel::GetPeers(query) => { + root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"get_peers".to_vec())); + let mut args = BTreeMap::new(); + args.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(query.node_id.clone()), + ); + args.insert( + b"info_hash".to_vec(), + DhtBencodeValue::Bytes(query.info_hash.clone()), + ); + root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args)); + } + DhtQueryModel::AnnouncePeer(query) => { + root.insert( + b"q".to_vec(), + DhtBencodeValue::Bytes(b"announce_peer".to_vec()), + ); + let mut args = BTreeMap::new(); + args.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(query.node_id.clone()), + ); + args.insert( + b"info_hash".to_vec(), + DhtBencodeValue::Bytes(query.info_hash.clone()), + ); + args.insert( + b"port".to_vec(), + DhtBencodeValue::Int(i64::from(query.port)), + ); + args.insert( + b"token".to_vec(), + DhtBencodeValue::Bytes(query.token.clone()), + ); + if query.implied_port { + args.insert(b"implied_port".to_vec(), DhtBencodeValue::Int(1)); + } + root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args)); + } + } + } + DhtMessageBody::Response(response) => { + root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"r".to_vec())); + let mut payload = BTreeMap::new(); + match response { + DhtResponseModel::Ping(response) => { + payload.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(response.node_id.clone()), + ); + } + DhtResponseModel::FindNode(response) => { + payload.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(response.node_id.clone()), + ); + if !response.nodes.is_empty() { + payload.insert( + b"nodes".to_vec(), + DhtBencodeValue::Bytes(encode_compact_dht_nodes(&response.nodes)), + ); + } + } + DhtResponseModel::GetPeers(response) => { + payload.insert( + b"id".to_vec(), + DhtBencodeValue::Bytes(response.node_id.clone()), + ); + if let Some(token) = &response.token { + payload + .insert(b"token".to_vec(), DhtBencodeValue::Bytes(token.clone())); + } + if let Some(nodes) = &response.nodes { + payload + .insert(b"nodes".to_vec(), DhtBencodeValue::Bytes(nodes.clone())); + } + if !response.values.is_empty() { + payload.insert( + b"values".to_vec(), + DhtBencodeValue::List( + response + .values + .iter() + .cloned() + .map(DhtBencodeValue::Bytes) + .collect(), + ), + ); + } + } + } + root.insert(b"r".to_vec(), DhtBencodeValue::Dict(payload)); + } + DhtMessageBody::Error(error) => { + root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"e".to_vec())); + root.insert( + b"e".to_vec(), + DhtBencodeValue::List(vec![ + DhtBencodeValue::Int(error.code), + DhtBencodeValue::Bytes(error.message.as_bytes().to_vec()), + ]), + ); + } + } + root + } +} + +/// Parses a DHT query message body from the decoded root dictionary. +fn parse_dht_query_message( + transaction_id: Vec, + root: &BTreeMap, DhtBencodeValue>, +) -> Result { + let method = + dht_dict_get_bytes(root, b"q").ok_or_else(|| "missing dht query method".to_owned())?; + let arguments = + dht_dict_get_dict(root, b"a").ok_or_else(|| "missing dht query arguments".to_owned())?; + let body = match method.as_slice() { + b"ping" => { + let node_id = dht_dict_get_bytes(arguments, b"id") + .ok_or_else(|| "missing dht ping id".to_owned())?; + DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel { node_id })) + } + b"find_node" => { + let node_id = dht_dict_get_bytes(arguments, b"id") + .ok_or_else(|| "missing dht find_node id".to_owned())?; + let target = dht_dict_get_bytes(arguments, b"target") + .ok_or_else(|| "missing dht find_node target".to_owned())?; + DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel { + node_id, + target, + })) + } + b"get_peers" => { + let node_id = dht_dict_get_bytes(arguments, b"id") + .ok_or_else(|| "missing dht get_peers id".to_owned())?; + let info_hash = dht_dict_get_bytes(arguments, b"info_hash") + .ok_or_else(|| "missing dht get_peers info_hash".to_owned())?; + DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel { + node_id, + info_hash, + })) + } + b"announce_peer" => { + let node_id = dht_dict_get_bytes(arguments, b"id") + .ok_or_else(|| "missing dht announce_peer id".to_owned())?; + let info_hash = dht_dict_get_bytes(arguments, b"info_hash") + .ok_or_else(|| "missing dht announce_peer info_hash".to_owned())?; + let port = dht_dict_get_int(arguments, b"port") + .ok_or_else(|| "missing dht announce_peer port".to_owned())?; + let token = dht_dict_get_bytes(arguments, b"token") + .ok_or_else(|| "missing dht announce_peer token".to_owned())?; + let implied_port = + dht_dict_get_int(arguments, b"implied_port").is_some_and(|value| value != 0); + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel { + node_id, + info_hash, + port: u16::try_from(port) + .map_err(|_| "dht announce_peer port out of range".to_owned())?, + token, + implied_port, + })) + } + _ => return Err("unsupported dht query method".to_owned()), + }; + Ok(DhtMessageModel { + transaction_id, + body, + }) +} + +/// Parses a DHT response message body from the decoded root dictionary. +fn parse_dht_response_message( + transaction_id: Vec, + root: &BTreeMap, DhtBencodeValue>, +) -> Result { + let payload = + dht_dict_get_dict(root, b"r").ok_or_else(|| "missing dht response body".to_owned())?; + let node_id = + dht_dict_get_bytes(payload, b"id").ok_or_else(|| "missing dht response id".to_owned())?; + let token = dht_dict_get_bytes(payload, b"token"); + let nodes = dht_dict_get_bytes(payload, b"nodes"); + let values = dht_dict_get_list(payload, b"values") + .unwrap_or_default() + .into_iter() + .map(|value| match value { + DhtBencodeValue::Bytes(bytes) => Ok(bytes), + _ => Err("dht response values entries must be byte strings".to_owned()), + }) + .collect::, _>>()?; + + let response = if token.is_some() || !values.is_empty() { + DhtResponseModel::GetPeers(DhtGetPeersResponseModel { + node_id, + token, + nodes, + values, + }) + } else if let Some(nodes) = nodes { + DhtResponseModel::FindNode(DhtFindNodeResponseModel { + node_id, + nodes: decode_compact_dht_nodes(&nodes)?, + }) + } else { + DhtResponseModel::Ping(DhtPingResponseModel { node_id }) + }; + + Ok(DhtMessageModel { + transaction_id, + body: DhtMessageBody::Response(response), + }) +} + +/// Parses a DHT error message body from the decoded root dictionary. +fn parse_dht_error_message( + transaction_id: Vec, + root: &BTreeMap, DhtBencodeValue>, +) -> Result { + let errors = + dht_dict_get_list(root, b"e").ok_or_else(|| "missing dht error payload".to_owned())?; + if errors.len() != 2 { + return Err("dht error payload must have [code, message]".to_owned()); + } + let code = match errors.first() { + Some(DhtBencodeValue::Int(value)) => *value, + _ => return Err("dht error code must be an integer".to_owned()), + }; + let message = match errors.get(1) { + Some(DhtBencodeValue::Bytes(bytes)) => String::from_utf8_lossy(bytes).into_owned(), + _ => return Err("dht error message must be bytes".to_owned()), + }; + Ok(DhtMessageModel { + transaction_id, + body: DhtMessageBody::Error(DhtErrorModel { code, message }), + }) +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/metadata.rs b/crates/aria2-rust-pro-protocol/src/torrent/metadata.rs new file mode 100644 index 0000000..137c668 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/metadata.rs @@ -0,0 +1,185 @@ +use sha1::{Digest, Sha1}; + +use super::{ + bencode::{BencodeValue, TorrentBencodeDict, dict_bytes, dict_int, parse_root_dict}, + model::{ + TorrentBootstrapModel, TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel, + TorrentMetadataModel, TorrentPieceModel, TorrentTrackerModel, + }, + utils::{bytes_to_string, hex_encode, i64_to_u64, value_to_string}, +}; + +/// Parses a `.torrent` payload into structured metadata. +/// +/// # Errors +/// +/// Returns an error when the bencoded payload is malformed or lacks the required info dictionary. +pub fn parse_torrent_metadata(input: &[u8]) -> Result { + let (root, info_raw) = parse_root_dict(input)?; + let info_map = match root.get("info") { + Some(BencodeValue::Dict(map)) => map, + Some(_) => return Err("torrent info must be a dictionary".to_owned()), + None => return Err("missing torrent info dictionary".to_owned()), + }; + let info_hash_hex = hex_encode(&Sha1::digest( + info_raw.ok_or_else(|| "missing info bytes".to_owned())?, + )); + let info = parse_info_model(info_map, info_hash_hex); + let announce = dict_bytes(&root, "announce").map(bytes_to_string); + let creation_date = dict_bytes(&root, "creation date").map(bytes_to_string); + let comment = dict_bytes(&root, "comment").map(bytes_to_string); + + Ok(TorrentMetadataModel { + pieces: build_piece_models(&info), + info, + announce, + trackers: parse_trackers(&root), + peers: Vec::new(), + dht_nodes: parse_dht_nodes(&root), + creation_date, + comment, + }) +} + +/// Parses a `.torrent` payload and immediately shapes it into bootstrap-ready metadata. +/// +/// # Errors +/// +/// Returns an error when the `.torrent` payload is malformed or the derived bootstrap fields +/// cannot be shaped. +pub fn parse_torrent_bootstrap(input: &[u8]) -> Result { + parse_torrent_metadata(input)?.bootstrap() +} + +/// Builds the higher-level torrent info model from the parsed `info` dictionary. +fn parse_info_model(info: &TorrentBencodeDict, info_hash_hex: String) -> TorrentInfoModel { + let name = dict_bytes(info, "name") + .map(bytes_to_string) + .unwrap_or_default(); + let piece_length = i64_to_u64(dict_int(info, "piece length").unwrap_or_default()); + let pieces = dict_bytes(info, "pieces") + .map(|bytes| { + bytes + .chunks_exact(20) + .map(|chunk| { + let mut hash = [0_u8; 20]; + hash.copy_from_slice(chunk); + hash + }) + .collect() + }) + .unwrap_or_default(); + let private = dict_int(info, "private").is_some_and(|value| value != 0); + let hash = Some(TorrentHashModel { + info_hash_hex, + info_hash_base32: None, + }); + let files = if let Some(BencodeValue::List(entries)) = info.get("files") { + let mut offset = 0_u64; + entries + .iter() + .filter_map(|entry| match entry { + BencodeValue::Dict(file) => { + let length = i64_to_u64(dict_int(file, "length").unwrap_or_default()); + let path = match file.get("path") { + Some(BencodeValue::List(parts)) => parts + .iter() + .map(value_to_string) + .collect::>() + .join("/"), + _ => String::new(), + }; + let item = TorrentFileEntryModel { + path, + length, + piece_offset: Some(offset), + selected: true, + }; + offset = offset.saturating_add(length); + Some(item) + } + _ => None, + }) + .collect() + } else { + vec![TorrentFileEntryModel { + path: name.clone(), + length: i64_to_u64(dict_int(info, "length").unwrap_or_default()), + piece_offset: Some(0), + selected: true, + }] + }; + + TorrentInfoModel { + name, + piece_length, + pieces, + files, + hash, + private, + } +} + +/// Derives piece descriptors with offsets and effective lengths from torrent metadata. +fn build_piece_models(info: &TorrentInfoModel) -> Vec { + info.pieces + .iter() + .enumerate() + .filter_map(|(index, hash)| { + u32::try_from(index).ok().map(|index| TorrentPieceModel { + index, + hash: *hash, + length: info.piece_length, + }) + }) + .collect() +} + +/// Parses the primary announce URL plus announce-list tiers into stable tracker entries. +fn parse_trackers(root: &TorrentBencodeDict) -> Vec { + let mut trackers = Vec::new(); + if let Some(url) = dict_bytes(root, "announce").map(bytes_to_string) { + trackers.push(TorrentTrackerModel { + url, + tier: Some(0), + id: None, + seeders: None, + leechers: None, + }); + } + if let Some(BencodeValue::List(tiers)) = root.get("announce-list") { + for (tier_index, tier) in tiers.iter().enumerate() { + if let BencodeValue::List(urls) = tier { + let tier = u32::try_from(tier_index) + .ok() + .and_then(|value| value.checked_add(1)); + for url in urls { + trackers.push(TorrentTrackerModel { + url: value_to_string(url), + tier, + id: None, + seeders: None, + leechers: None, + }); + } + } + } + } + trackers +} + +/// Parses DHT bootstrap nodes from the optional `nodes` field. +fn parse_dht_nodes(root: &TorrentBencodeDict) -> Vec { + match root.get("nodes") { + Some(BencodeValue::List(nodes)) => nodes + .iter() + .filter_map(|node| match node { + BencodeValue::List(parts) => parts.first().zip(parts.get(1)).map(|(host, port)| { + format!("{}:{}", value_to_string(host), value_to_string(port)) + }), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/model.rs b/crates/aria2-rust-pro-protocol/src/torrent/model.rs new file mode 100644 index 0000000..d3537de --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/model.rs @@ -0,0 +1,281 @@ +use crate::{ + magnet::MagnetUriModel, + tracker::{DhtNodeModel, TrackerRequestModel}, +}; + +use super::utils::decode_hex_20_array; + +/// Derived info-hash encodings for one parsed torrent info dictionary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentHashModel { + /// Lowercase hexadecimal SHA-1 info-hash. + pub info_hash_hex: String, + /// Optional base32-encoded SHA-1 info-hash. + pub info_hash_base32: Option, +} + +/// One torrent piece with its index, SHA-1 hash, and visible byte length. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TorrentPieceModel { + /// Zero-based piece index. + pub index: u32, + /// Raw 20-byte SHA-1 piece hash. + pub hash: [u8; 20], + /// Declared byte length of the piece. + pub length: u64, +} + +/// One file entry from the torrent info dictionary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentFileEntryModel { + /// Normalized relative path of the file inside the torrent payload. + pub path: String, + /// Declared byte length of the file. + pub length: u64, + /// Byte offset where this file begins within the concatenated torrent payload. + pub piece_offset: Option, + /// Whether the file is currently selected for download. + pub selected: bool, +} + +/// Parsed contents of the torrent info dictionary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentInfoModel { + /// Display name of the torrent or root directory. + pub name: String, + /// Declared piece length in bytes. + pub piece_length: u64, + /// Raw SHA-1 piece hashes in info-dictionary order. + pub pieces: Vec<[u8; 20]>, + /// File list represented by the torrent. + pub files: Vec, + /// Precomputed info-hash encodings, when the raw info dictionary was available. + pub hash: Option, + /// Whether the torrent declares the private flag. + pub private: bool, +} + +/// One announce or scrape tracker entry associated with the torrent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentTrackerModel { + /// Tracker URL. + pub url: String, + /// Optional announce-list tier index. + pub tier: Option, + /// Optional tracker id returned by the tracker. + pub id: Option, + /// Optional reported seeder count. + pub seeders: Option, + /// Optional reported leecher count. + pub leechers: Option, +} + +/// One peer surfaced through torrent runtime or tracker responses. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentPeerModel { + /// Optional 20-byte peer id. + pub peer_id: Option<[u8; 20]>, + /// Peer IP address in string form. + pub ip: String, + /// Peer port. + pub port: u16, + /// Optional peer client name. + pub client_name: Option, + /// Whether the peer is interested in local pieces. + pub interested: bool, + /// Whether the peer is currently choking the local side. + pub choked: bool, +} + +/// Full parsed torrent metadata plus runtime-adjacent tracker and peer surfaces. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentMetadataModel { + /// Parsed info dictionary. + pub info: TorrentInfoModel, + /// Primary announce URL, if present. + pub announce: Option, + /// Flattened tracker list with stable tier annotations. + pub trackers: Vec, + /// Known peers currently associated with the torrent. + pub peers: Vec, + /// DHT bootstrap nodes from the `nodes` list, normalized as `host:port` strings. + pub dht_nodes: Vec, + /// Derived piece models with offsets and lengths. + pub pieces: Vec, + /// Optional creation date text carried by the torrent. + pub creation_date: Option, + /// Optional comment text carried by the torrent. + pub comment: Option, +} + +/// Higher-level `.torrent` bootstrap data suitable for dispatcher / CLI handoff. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentBootstrapModel { + /// Fully parsed torrent metadata. + pub metadata: TorrentMetadataModel, + /// Lowercase hexadecimal SHA-1 info-hash. + pub info_hash_hex: String, + /// Raw 20-byte SHA-1 info-hash. + pub info_hash_bytes: [u8; 20], + /// Magnet projection derived from the torrent metadata. + pub magnet: MagnetUriModel, + /// Parsed DHT node models ready for DHT/bootstrap orchestration. + pub dht_nodes: Vec, +} + +impl TorrentPeerModel { + /// Parses a peer endpoint from `host:port` or `[ipv6]:port` syntax. + /// + /// # Errors + /// + /// Returns an error when the endpoint is malformed. + pub fn from_endpoint(raw: &str) -> Result { + let node = DhtNodeModel::from_spec(raw).map_err(|error| error.to_string())?; + Ok(Self { + peer_id: None, + ip: node.address, + port: node.port, + client_name: None, + interested: false, + choked: false, + }) + } + + /// Formats the peer as a stable endpoint string. + #[must_use] + pub fn endpoint(&self) -> String { + self.to_dht_node().to_spec() + } + + /// Shapes the peer endpoint into a DHT/bootstrap node model. + #[must_use] + pub fn to_dht_node(&self) -> DhtNodeModel { + DhtNodeModel { + node_id: String::new(), + address: self.ip.clone(), + port: self.port, + } + } +} + +impl TorrentMetadataModel { + #[must_use] + /// Returns the total payload length across all torrent files. + pub fn total_length(&self) -> u64 { + self.info.files.iter().map(|file| file.length).sum() + } + + /// Returns the torrent info-hash as lowercase hexadecimal text. + #[must_use] + pub fn info_hash_hex(&self) -> Option<&str> { + self.info + .hash + .as_ref() + .map(|hash| hash.info_hash_hex.as_str()) + } + + /// Decodes the torrent info-hash into its raw 20-byte SHA-1 representation. + /// + /// # Errors + /// + /// Returns an error when the torrent metadata does not carry an info-hash. + pub fn info_hash_bytes(&self) -> Result<[u8; 20], String> { + decode_hex_20_array( + self.info_hash_hex() + .ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?, + ) + } + + /// Returns the tracker URLs in stable announce order. + #[must_use] + pub fn tracker_urls(&self) -> Vec { + self.trackers + .iter() + .map(|tracker| tracker.url.clone()) + .collect() + } + + /// Returns the first tracker URL when present. + #[must_use] + pub fn primary_tracker_url(&self) -> Option<&str> { + self.trackers.first().map(|tracker| tracker.url.as_str()) + } + + /// Projects the torrent metadata into a canonical magnet URI model. + /// + /// # Errors + /// + /// Returns an error when the torrent metadata does not carry an info-hash. + pub fn magnet_uri_model(&self) -> Result { + Ok(MagnetUriModel { + info_hash: self + .info_hash_hex() + .ok_or_else(|| "torrent metadata is missing info-hash".to_owned())? + .to_owned(), + display_name: Some(self.info.name.clone()), + trackers: self.tracker_urls(), + web_seeds: Vec::new(), + exact_topic: None, + }) + } + + /// Parses stored DHT node specs into higher-level node models. + /// + /// # Errors + /// + /// Returns an error when any stored node spec is malformed. + pub fn dht_node_models(&self) -> Result, String> { + self.dht_nodes + .iter() + .map(|node| DhtNodeModel::from_spec(node).map_err(|error| error.to_string())) + .collect() + } + + /// Shapes the torrent metadata into a bootstrap model suitable for `.torrent` handoff. + /// + /// # Errors + /// + /// Returns an error when the torrent metadata does not carry a usable info-hash or contains + /// malformed DHT node specs. + pub fn bootstrap(&self) -> Result { + Ok(TorrentBootstrapModel { + metadata: self.clone(), + info_hash_hex: self + .info_hash_hex() + .ok_or_else(|| "torrent metadata is missing info-hash".to_owned())? + .to_owned(), + info_hash_bytes: self.info_hash_bytes()?, + magnet: self.magnet_uri_model()?, + dht_nodes: self.dht_node_models()?, + }) + } + + #[must_use] + /// Builds a tracker announce request from the parsed torrent metadata. + pub fn tracker_request( + &self, + announce_url: impl Into, + peer_id: impl Into, + port: u16, + uploaded: u64, + downloaded: u64, + ) -> TrackerRequestModel { + TrackerRequestModel { + announce_url: announce_url.into(), + info_hash: self + .info + .hash + .as_ref() + .map(|hash| hash.info_hash_hex.clone()) + .unwrap_or_default(), + peer_id: peer_id.into(), + port, + uploaded, + downloaded, + left: self.total_length().saturating_sub(downloaded), + event: None, + compact: true, + numwant: Some(50), + } + } +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/peer_wire.rs b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire.rs new file mode 100644 index 0000000..dad19ab --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire.rs @@ -0,0 +1,223 @@ +use std::collections::BTreeMap; + +use super::{ + bencode::{ + BencodeValue, dict_bytes, dict_int, encode_bencode_root, parse_bencode_root_exact, + parse_bencode_root_prefix, + }, + utils::{bytes_to_string, i64_to_u64}, +}; + +/// BEP 10 extension-protocol handshake and metadata helpers. +mod extension; +/// Peer-wire frame parsing and serialization helpers. +mod framing; +/// `BitTorrent` handshake parsing and serialization helpers. +mod handshake; + +/// Generic torrent message wrapper reused by higher-level peer-wire helpers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TorrentMessageModel { + /// Canonical internal message type name. + pub message_type: String, + /// Raw payload bytes excluding transport framing. + pub payload: Vec, +} + +/// `BitTorrent` peer-wire handshake header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerWireHandshakeModel { + /// Reserved extension bits. + pub reserved: [u8; 8], + /// 20-byte torrent info-hash. + pub info_hash: [u8; 20], + /// 20-byte local peer id. + pub peer_id: [u8; 20], +} + +/// One parsed peer-wire message, optionally associated with a peer id. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireMessageModel { + /// Optional peer id attached by higher-level wrappers. + pub peer_id: Option<[u8; 20]>, + /// Parsed message payload. + pub message: TorrentMessageModel, +} + +/// Lightweight inspection result for a framed peer-wire message. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerWireFrameHeaderModel { + /// Optional peer-wire message id. `None` represents keepalive. + pub message_id: Option, + /// Payload length excluding the length prefix and optional message id byte. + pub payload_len: usize, +} + +/// Packed peer-wire bitfield bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireBitfieldModel { + /// Raw bitfield bytes in network order. + pub bytes: Vec, +} + +/// Piece request or cancel coordinates for the peer-wire protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerWireBlockRequestModel { + /// Zero-based piece index. + pub piece_index: u32, + /// Byte offset within the piece. + pub block_offset: u32, + /// Requested block length in bytes. + pub block_length: u32, +} + +/// Piece payload delivered through the peer-wire protocol. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWirePieceBlockModel { + /// Zero-based piece index. + pub piece_index: u32, + /// Byte offset within the piece. + pub block_offset: u32, + /// Raw block bytes. + pub block: Vec, +} + +/// Extension-protocol message payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireExtensionMessageModel { + /// Extension message id. + pub extension_message_id: u8, + /// Extension payload bytes after the extension id. + pub payload: Vec, +} + +/// Parsed extended-handshake payload from BEP 10. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireExtensionHandshakeModel { + /// Named extension ids announced under the `m` dictionary. + pub extensions: BTreeMap, + /// Optional peer/client version string from `v`. + pub client_name: Option, + /// Optional BEP 9 metadata byte length. + pub metadata_size: Option, + /// Optional request queue depth from `reqq`. + pub request_queue: Option, +} + +/// BEP 9 `ut_metadata` message type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PeerWireMetadataMessageType { + /// Requests one metadata piece. + Request, + /// Carries one metadata piece payload. + Data, + /// Rejects one metadata piece request. + Reject, +} + +impl PeerWireMetadataMessageType { + /// Returns the BEP 9 wire value for the metadata message type. + #[must_use] + pub const fn wire_value(self) -> u8 { + match self { + Self::Request => 0, + Self::Data => 1, + Self::Reject => 2, + } + } + + /// Decodes one BEP 9 wire value into the typed metadata message kind. + fn from_wire_value(value: i64) -> Result { + match value { + 0 => Ok(Self::Request), + 1 => Ok(Self::Data), + 2 => Ok(Self::Reject), + _ => Err(format!("unsupported ut_metadata msg_type: {value}")), + } + } +} + +/// Default BEP 9 metadata piece size in bytes. +pub const PEER_WIRE_METADATA_PIECE_SIZE: u32 = 16 * 1024; + +/// Parsed BEP 9 `ut_metadata` message with header and optional payload bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireMetadataMessageModel { + /// Metadata message subtype. + pub message_type: PeerWireMetadataMessageType, + /// Metadata piece index addressed by the message. + pub piece: u32, + /// Total metadata byte length when included in `data` messages. + pub total_size: Option, + /// Metadata payload bytes for `data` messages. + pub payload: Vec, +} + +/// Unknown peer-wire message payload retained losslessly. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireUnknownMessageModel { + /// Raw peer-wire message id. + pub message_id: u8, + /// Unparsed message payload bytes. + pub payload: Vec, +} + +/// Supported peer-wire message variants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PeerWireMessageKind { + /// Zero-length keepalive frame. + KeepAlive, + /// Choke control message. + Choke, + /// Unchoke control message. + Unchoke, + /// Interested control message. + Interested, + /// Not-interested control message. + NotInterested, + /// `have` message naming one completed piece. + Have(u32), + /// `bitfield` message. + Bitfield(PeerWireBitfieldModel), + /// `request` message. + Request(PeerWireBlockRequestModel), + /// `piece` message. + Piece(PeerWirePieceBlockModel), + /// `cancel` message. + Cancel(PeerWireBlockRequestModel), + /// `port` DHT advertisement message. + Port(u16), + /// Extension-protocol message. + Extension(PeerWireExtensionMessageModel), + /// Unknown message preserved losslessly. + Unknown(PeerWireUnknownMessageModel), +} + +/// Canonical protocol string embedded in peer-wire handshakes. +const PEER_WIRE_PROTOCOL_NAME: &str = "BitTorrent protocol"; +/// Byte length of [`PEER_WIRE_PROTOCOL_NAME`]. +const PEER_WIRE_PROTOCOL_LEN: u8 = 19; +/// Handshake bytes following the protocol-length octet and protocol string. +const PEER_WIRE_HANDSHAKE_PREFIX_LEN: usize = 49; +/// Peer-wire message id for `choke`. +const PEER_WIRE_CHOKE_ID: u8 = 0; +/// Peer-wire message id for `unchoke`. +const PEER_WIRE_UNCHOKE_ID: u8 = 1; +/// Peer-wire message id for `interested`. +const PEER_WIRE_INTERESTED_ID: u8 = 2; +/// Peer-wire message id for `not interested`. +const PEER_WIRE_NOT_INTERESTED_ID: u8 = 3; +/// Peer-wire message id for `have`. +const PEER_WIRE_HAVE_ID: u8 = 4; +/// Peer-wire message id for `bitfield`. +const PEER_WIRE_BITFIELD_ID: u8 = 5; +/// Peer-wire message id for `request`. +const PEER_WIRE_REQUEST_ID: u8 = 6; +/// Peer-wire message id for `piece`. +const PEER_WIRE_PIECE_ID: u8 = 7; +/// Peer-wire message id for `cancel`. +const PEER_WIRE_CANCEL_ID: u8 = 8; +/// Peer-wire message id for `port`. +const PEER_WIRE_PORT_ID: u8 = 9; +/// Peer-wire message id for extension-protocol payloads. +const PEER_WIRE_EXTENSION_ID: u8 = 20; diff --git a/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/extension.rs b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/extension.rs new file mode 100644 index 0000000..2002471 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/extension.rs @@ -0,0 +1,291 @@ +use std::collections::BTreeMap; + +use super::{ + BencodeValue, PEER_WIRE_METADATA_PIECE_SIZE, PeerWireExtensionHandshakeModel, + PeerWireExtensionMessageModel, PeerWireMetadataMessageModel, PeerWireMetadataMessageType, + bytes_to_string, dict_bytes, dict_int, encode_bencode_root, i64_to_u64, + parse_bencode_root_exact, parse_bencode_root_prefix, +}; + +impl PeerWireExtensionHandshakeModel { + /// Returns the announced `ut_metadata` extension id when present. + #[must_use] + pub fn ut_metadata_id(&self) -> Option { + self.extensions + .get("ut_metadata") + .copied() + .filter(|id| *id != 0) + } + + /// Returns the advertised metadata payload size as piece count when present. + #[must_use] + pub fn metadata_piece_count(&self) -> Option { + self.metadata_size.map(metadata_piece_count) + } + + /// Serializes the handshake into a BEP 10 bencoded dictionary. + #[must_use] + pub fn to_bencode_bytes(&self) -> Vec { + let mut root = BTreeMap::new(); + let mut extensions = BTreeMap::new(); + for (name, id) in &self.extensions { + extensions.insert(name.clone(), BencodeValue::Int(i64::from(*id))); + } + root.insert("m".to_owned(), BencodeValue::Dict(extensions)); + if let Some(client_name) = &self.client_name { + root.insert( + "v".to_owned(), + BencodeValue::Bytes(client_name.as_bytes().to_vec()), + ); + } + if let Some(metadata_size) = self.metadata_size { + root.insert( + "metadata_size".to_owned(), + BencodeValue::Int(i64::from(metadata_size)), + ); + } + if let Some(request_queue) = self.request_queue { + root.insert( + "reqq".to_owned(), + BencodeValue::Int(i64::from(request_queue)), + ); + } + encode_bencode_root(&root) + } + + /// Parses a BEP 10 extended-handshake dictionary. + /// + /// # Errors + /// + /// Returns an error when the payload is not a valid handshake dictionary. + pub fn from_bencode_bytes(input: &[u8]) -> Result { + let root = parse_bencode_root_exact(input)?; + let mut extensions = BTreeMap::new(); + if let Some(BencodeValue::Dict(values)) = root.get("m") { + for (name, value) in values { + if let BencodeValue::Int(id) = value { + let id_u8 = u8::try_from(*id) + .map_err(|_| format!("extension id for {name} does not fit u8"))?; + extensions.insert(name.clone(), id_u8); + } + } + } + + Ok(Self { + extensions, + client_name: dict_bytes(&root, "v").map(bytes_to_string), + metadata_size: dict_int(&root, "metadata_size") + .map(i64_to_u64) + .map(u32::try_from) + .transpose() + .map_err(|_| "metadata_size does not fit u32".to_owned())?, + request_queue: dict_int(&root, "reqq") + .map(i64_to_u64) + .map(u32::try_from) + .transpose() + .map_err(|_| "reqq does not fit u32".to_owned())?, + }) + } + + /// Wraps the handshake as a peer-wire extension message with extended-message id `0`. + #[must_use] + pub fn to_peer_wire_message(&self) -> PeerWireExtensionMessageModel { + PeerWireExtensionMessageModel { + extension_message_id: 0, + payload: self.to_bencode_bytes(), + } + } + + /// Parses a peer-wire extended handshake message. + /// + /// # Errors + /// + /// Returns an error when the message is not the extension handshake or the payload is invalid. + pub fn from_peer_wire_message(message: &PeerWireExtensionMessageModel) -> Result { + if message.extension_message_id != 0 { + return Err(format!( + "extended handshake must use extension message id 0, got {}", + message.extension_message_id + )); + } + Self::from_bencode_bytes(&message.payload) + } +} + +impl PeerWireMetadataMessageModel { + /// Builds a BEP 9 metadata request for one piece index. + #[must_use] + pub fn request(piece: u32) -> Self { + Self { + message_type: PeerWireMetadataMessageType::Request, + piece, + total_size: None, + payload: Vec::new(), + } + } + + /// Builds a BEP 9 metadata data message for one piece index. + #[must_use] + pub fn data(piece: u32, total_size: u32, payload: Vec) -> Self { + Self { + message_type: PeerWireMetadataMessageType::Data, + piece, + total_size: Some(total_size), + payload, + } + } + + /// Builds a BEP 9 metadata reject message for one piece index. + #[must_use] + pub fn reject(piece: u32) -> Self { + Self { + message_type: PeerWireMetadataMessageType::Reject, + piece, + total_size: None, + payload: Vec::new(), + } + } + + /// Wraps the metadata message into a peer-wire extension payload using the supplied id. + #[must_use] + pub fn to_peer_wire_message(&self, extension_message_id: u8) -> PeerWireExtensionMessageModel { + PeerWireExtensionMessageModel { + extension_message_id, + payload: self.to_bencode_bytes(), + } + } + + /// Serializes the BEP 9 header plus any trailing metadata payload. + #[must_use] + pub fn to_bencode_bytes(&self) -> Vec { + let mut root = BTreeMap::new(); + root.insert( + "msg_type".to_owned(), + BencodeValue::Int(i64::from(self.message_type.wire_value())), + ); + root.insert("piece".to_owned(), BencodeValue::Int(i64::from(self.piece))); + if self.message_type == PeerWireMetadataMessageType::Data + && let Some(total_size) = self.total_size + { + root.insert( + "total_size".to_owned(), + BencodeValue::Int(i64::from(total_size)), + ); + } + let mut out = encode_bencode_root(&root); + if self.message_type == PeerWireMetadataMessageType::Data { + out.extend_from_slice(&self.payload); + } + out + } + + /// Parses a BEP 9 message from raw extension payload bytes. + /// + /// # Errors + /// + /// Returns an error when the message header is malformed or contains unsupported values. + pub fn from_bencode_bytes(input: &[u8]) -> Result { + let (root, consumed) = parse_bencode_root_prefix(input)?; + let message_type_raw = + dict_int(&root, "msg_type").ok_or_else(|| "missing ut_metadata msg_type".to_owned())?; + let message_type = PeerWireMetadataMessageType::from_wire_value(message_type_raw)?; + let piece = dict_int(&root, "piece") + .ok_or_else(|| "missing ut_metadata piece".to_owned()) + .map(i64_to_u64) + .and_then(|value| { + u32::try_from(value).map_err(|_| "ut_metadata piece does not fit u32".to_owned()) + })?; + let total_size = dict_int(&root, "total_size") + .map(i64_to_u64) + .map(u32::try_from) + .transpose() + .map_err(|_| "ut_metadata total_size does not fit u32".to_owned())?; + let payload = input[consumed..].to_vec(); + if message_type != PeerWireMetadataMessageType::Data && total_size.is_some() { + return Err( + "ut_metadata request/reject messages must not include total_size".to_owned(), + ); + } + if message_type != PeerWireMetadataMessageType::Data && !payload.is_empty() { + return Err( + "ut_metadata request/reject messages must not carry trailing payload".to_owned(), + ); + } + if message_type == PeerWireMetadataMessageType::Data { + let total_size = total_size + .ok_or_else(|| "ut_metadata data messages must include total_size".to_owned())?; + let expected_payload_len = metadata_piece_len(piece, total_size)?; + if payload.len() != expected_payload_len { + return Err(format!( + "ut_metadata data payload length {} does not match expected {} bytes for piece {}", + payload.len(), + expected_payload_len, + piece + )); + } + } + + Ok(Self { + message_type, + piece, + total_size, + payload, + }) + } + + /// Parses a peer-wire extension message as a BEP 9 metadata message. + /// + /// # Errors + /// + /// Returns an error when the extension id mismatches or the payload is malformed. + pub fn from_peer_wire_message( + message: &PeerWireExtensionMessageModel, + expected_extension_message_id: u8, + ) -> Result { + if expected_extension_message_id == 0 { + return Err("ut_metadata cannot use peer-wire extension message id 0".to_owned()); + } + if message.extension_message_id != expected_extension_message_id { + return Err(format!( + "ut_metadata message expected extension id {expected_extension_message_id}, got {}", + message.extension_message_id + )); + } + Self::from_bencode_bytes(&message.payload) + } +} + +/// Returns the number of metadata pieces required to carry `total_size` bytes. +#[must_use] +fn metadata_piece_count(total_size: u32) -> u32 { + if total_size == 0 { + return 0; + } + (total_size - 1) / PEER_WIRE_METADATA_PIECE_SIZE + 1 +} + +/// Returns the expected payload length for one metadata piece. +/// +/// # Errors +/// +/// Returns an error when the total size is zero or the requested piece is out of range. +fn metadata_piece_len(piece: u32, total_size: u32) -> Result { + if total_size == 0 { + return Err("ut_metadata total_size must be positive".to_owned()); + } + let piece_count = metadata_piece_count(total_size); + if piece >= piece_count { + return Err(format!( + "ut_metadata piece {piece} is out of range for total_size {total_size}" + )); + } + + let piece_size = PEER_WIRE_METADATA_PIECE_SIZE; + let base_offset = piece + .checked_mul(piece_size) + .ok_or_else(|| "ut_metadata piece offset overflow".to_owned())?; + let remaining = total_size - base_offset; + let expected_len = remaining.min(piece_size); + usize::try_from(expected_len) + .map_err(|_| "ut_metadata payload length does not fit usize".to_owned()) +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/framing.rs b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/framing.rs new file mode 100644 index 0000000..7a761f5 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/framing.rs @@ -0,0 +1,506 @@ +use super::{ + PEER_WIRE_BITFIELD_ID, PEER_WIRE_CANCEL_ID, PEER_WIRE_CHOKE_ID, PEER_WIRE_EXTENSION_ID, + PEER_WIRE_HAVE_ID, PEER_WIRE_INTERESTED_ID, PEER_WIRE_NOT_INTERESTED_ID, PEER_WIRE_PIECE_ID, + PEER_WIRE_PORT_ID, PEER_WIRE_REQUEST_ID, PEER_WIRE_UNCHOKE_ID, PeerWireBitfieldModel, + PeerWireBlockRequestModel, PeerWireExtensionMessageModel, PeerWireFrameHeaderModel, + PeerWireMessageKind, PeerWireMessageModel, PeerWirePieceBlockModel, + PeerWireUnknownMessageModel, TorrentMessageModel, +}; + +impl PeerWireMessageModel { + #[must_use] + /// Wraps one parsed torrent message with an optional peer id. + pub fn new(peer_id: Option<[u8; 20]>, message: TorrentMessageModel) -> Self { + Self { peer_id, message } + } + + /// Parses one framed peer-wire message and reports the number of bytes consumed. + /// + /// # Errors + /// + /// Returns an error when the frame header or payload is malformed. + pub fn parse_frame(input: &[u8]) -> Result<(Self, usize), String> { + let (message, consumed) = TorrentMessageModel::parse_peer_wire_frame(input)?; + Ok(( + Self { + peer_id: None, + message, + }, + consumed, + )) + } + + /// Parses one complete framed peer-wire message. + /// + /// # Errors + /// + /// Returns an error when the frame is malformed or contains trailing bytes. + pub fn parse_frame_exact(input: &[u8]) -> Result { + let (message, consumed) = Self::parse_frame(input)?; + if consumed != input.len() { + return Err("trailing bytes after peer-wire frame".to_owned()); + } + Ok(message) + } + + /// Serializes the wrapped message as a framed peer-wire payload. + /// + /// # Errors + /// + /// Returns an error when the inner message cannot be represented as peer-wire bytes. + pub fn serialize_frame(&self) -> Result, String> { + self.message.serialize_peer_wire_frame() + } +} + +impl PeerWireBitfieldModel { + #[must_use] + /// Builds a bitfield from per-piece completion flags. + pub fn from_piece_flags(flags: &[bool]) -> Self { + let mut bytes = vec![0_u8; flags.len().div_ceil(8)]; + for (index, &present) in flags.iter().enumerate() { + if present { + bytes[index / 8] |= 1 << (7 - (index % 8)); + } + } + Self { bytes } + } + + #[must_use] + /// Returns the maximum number of pieces represented by this bitfield. + pub fn piece_capacity(&self) -> usize { + self.bytes.len() * 8 + } + + #[must_use] + /// Returns whether the bitfield marks `piece_index` as present. + pub fn has_piece(&self, piece_index: usize) -> bool { + let byte = piece_index / 8; + let bit = piece_index % 8; + self.bytes + .get(byte) + .is_some_and(|value| value & (1 << (7 - bit)) != 0) + } + + #[must_use] + /// Expands the bitfield into per-piece completion flags. + pub fn to_piece_flags(&self, piece_count: usize) -> Vec { + (0..piece_count) + .map(|index| self.has_piece(index)) + .collect() + } +} + +impl TorrentMessageModel { + #[must_use] + /// Builds an internal torrent message from a peer-wire message variant. + pub fn from_peer_wire_kind(kind: PeerWireMessageKind) -> Self { + match kind { + PeerWireMessageKind::KeepAlive => Self { + message_type: "keepalive".to_owned(), + payload: Vec::new(), + }, + PeerWireMessageKind::Choke => Self { + message_type: "choke".to_owned(), + payload: Vec::new(), + }, + PeerWireMessageKind::Unchoke => Self { + message_type: "unchoke".to_owned(), + payload: Vec::new(), + }, + PeerWireMessageKind::Interested => Self { + message_type: "interested".to_owned(), + payload: Vec::new(), + }, + PeerWireMessageKind::NotInterested => Self { + message_type: "not_interested".to_owned(), + payload: Vec::new(), + }, + PeerWireMessageKind::Have(piece_index) => Self { + message_type: "have".to_owned(), + payload: piece_index.to_be_bytes().to_vec(), + }, + PeerWireMessageKind::Bitfield(bitfield) => Self { + message_type: "bitfield".to_owned(), + payload: bitfield.bytes, + }, + PeerWireMessageKind::Request(request) => Self { + message_type: "request".to_owned(), + payload: encode_block_request_payload(&request), + }, + PeerWireMessageKind::Piece(piece) => Self { + message_type: "piece".to_owned(), + payload: encode_piece_payload(&piece), + }, + PeerWireMessageKind::Cancel(request) => Self { + message_type: "cancel".to_owned(), + payload: encode_block_request_payload(&request), + }, + PeerWireMessageKind::Port(port) => Self { + message_type: "port".to_owned(), + payload: port.to_be_bytes().to_vec(), + }, + PeerWireMessageKind::Extension(extension) => { + let mut payload = Vec::with_capacity(1 + extension.payload.len()); + payload.push(extension.extension_message_id); + payload.extend_from_slice(&extension.payload); + Self { + message_type: "extension".to_owned(), + payload, + } + } + PeerWireMessageKind::Unknown(message) => Self { + message_type: format!("unknown:{}", message.message_id), + payload: message.payload, + }, + } + } + + /// Reconstructs the typed peer-wire message kind from the internal message payload. + /// + /// # Errors + /// + /// Returns an error when the message type or payload shape is unsupported. + pub fn peer_wire_kind(&self) -> Result { + peer_wire_kind_from_raw(&self.message_type, &self.payload) + } + + /// Inspects a framed peer-wire message header without fully decoding the payload. + /// + /// # Errors + /// + /// Returns an error when the frame is truncated or malformed. + pub fn inspect_peer_wire_frame( + input: &[u8], + ) -> Result<(PeerWireFrameHeaderModel, usize), String> { + if input.len() < 4 { + return Err("truncated peer-wire frame: missing length prefix".to_owned()); + } + + let frame_len = + usize::try_from(u32::from_be_bytes([input[0], input[1], input[2], input[3]])) + .map_err(|_| "peer-wire frame length does not fit usize".to_owned())?; + let total_len = 4_usize + .checked_add(frame_len) + .ok_or_else(|| "peer-wire frame length overflow".to_owned())?; + if input.len() < total_len { + return Err(format!( + "truncated peer-wire frame: expected {total_len} bytes, got {}", + input.len() + )); + } + + if frame_len == 0 { + return Ok(( + PeerWireFrameHeaderModel { + message_id: None, + payload_len: 0, + }, + total_len, + )); + } + + let message_id = input[4]; + Ok(( + PeerWireFrameHeaderModel { + message_id: Some(message_id), + payload_len: frame_len - 1, + }, + total_len, + )) + } + + /// Parses a framed peer-wire message and reports the number of consumed bytes. + /// + /// # Errors + /// + /// Returns an error when the frame is truncated or malformed. + pub fn parse_peer_wire_frame(input: &[u8]) -> Result<(Self, usize), String> { + let (header, consumed) = Self::inspect_peer_wire_frame(input)?; + let Some(message_id) = header.message_id else { + return Ok(( + Self::from_peer_wire_kind(PeerWireMessageKind::KeepAlive), + consumed, + )); + }; + + let payload = &input[5..consumed]; + let kind = peer_wire_kind_from_message_id(message_id, payload)?; + Ok((Self::from_peer_wire_kind(kind), consumed)) + } + + /// Parses one complete framed peer-wire message. + /// + /// # Errors + /// + /// Returns an error when the frame is truncated, malformed, or has trailing bytes. + pub fn parse_peer_wire_frame_exact(input: &[u8]) -> Result { + let (message, consumed) = Self::parse_peer_wire_frame(input)?; + if consumed != input.len() { + return Err("trailing bytes after peer-wire frame".to_owned()); + } + Ok(message) + } + + /// Serializes the message as a framed peer-wire payload. + /// + /// # Errors + /// + /// Returns an error when the message cannot be represented as a supported peer-wire frame. + pub fn serialize_peer_wire_frame(&self) -> Result, String> { + let kind = self.peer_wire_kind()?; + serialize_peer_wire_kind(&kind) + } +} + +/// Serializes one peer-wire message kind into a framed peer-wire payload. +fn serialize_peer_wire_kind(kind: &PeerWireMessageKind) -> Result, String> { + let mut payload = Vec::new(); + let message_id = match kind { + PeerWireMessageKind::KeepAlive => None, + PeerWireMessageKind::Choke => Some(PEER_WIRE_CHOKE_ID), + PeerWireMessageKind::Unchoke => Some(PEER_WIRE_UNCHOKE_ID), + PeerWireMessageKind::Interested => Some(PEER_WIRE_INTERESTED_ID), + PeerWireMessageKind::NotInterested => Some(PEER_WIRE_NOT_INTERESTED_ID), + PeerWireMessageKind::Have(piece_index) => { + payload.extend_from_slice(&piece_index.to_be_bytes()); + Some(PEER_WIRE_HAVE_ID) + } + PeerWireMessageKind::Bitfield(bitfield) => { + payload.extend_from_slice(&bitfield.bytes); + Some(PEER_WIRE_BITFIELD_ID) + } + PeerWireMessageKind::Request(request) => { + payload.extend_from_slice(&encode_block_request_payload(request)); + Some(PEER_WIRE_REQUEST_ID) + } + PeerWireMessageKind::Piece(piece) => { + payload.extend_from_slice(&encode_piece_payload(piece)); + Some(PEER_WIRE_PIECE_ID) + } + PeerWireMessageKind::Cancel(request) => { + payload.extend_from_slice(&encode_block_request_payload(request)); + Some(PEER_WIRE_CANCEL_ID) + } + PeerWireMessageKind::Port(port) => { + payload.extend_from_slice(&port.to_be_bytes()); + Some(PEER_WIRE_PORT_ID) + } + PeerWireMessageKind::Extension(extension) => { + payload.push(extension.extension_message_id); + payload.extend_from_slice(&extension.payload); + Some(PEER_WIRE_EXTENSION_ID) + } + PeerWireMessageKind::Unknown(message) => { + payload.extend_from_slice(&message.payload); + Some(message.message_id) + } + }; + + let Some(message_id) = message_id else { + return Ok(vec![0, 0, 0, 0]); + }; + + let frame_len = 1 + payload.len(); + let frame_len_u32 = u32::try_from(frame_len) + .map_err(|_| "peer-wire frame exceeds u32 length prefix".to_owned())?; + + let mut bytes = Vec::with_capacity(4 + frame_len); + bytes.extend_from_slice(&frame_len_u32.to_be_bytes()); + bytes.push(message_id); + bytes.extend_from_slice(&payload); + Ok(bytes) +} + +/// Interprets one internal message-type label and payload as a peer-wire message kind. +fn peer_wire_kind_from_raw( + message_type: &str, + payload: &[u8], +) -> Result { + match message_type { + "keepalive" => { + expect_empty_payload("keepalive", payload).map(|()| PeerWireMessageKind::KeepAlive) + } + "choke" => expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke), + "unchoke" => { + expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke) + } + "interested" => { + expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested) + } + "not_interested" | "not-interested" => expect_empty_payload("not_interested", payload) + .map(|()| PeerWireMessageKind::NotInterested), + "have" => parse_have_payload(payload), + "bitfield" => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel { + bytes: payload.to_vec(), + })), + "request" => { + parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request) + } + "piece" => parse_piece_payload(payload).map(PeerWireMessageKind::Piece), + "cancel" => parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel), + "port" => parse_port_payload(payload), + "extension" => parse_extension_payload(payload), + _ => parse_unknown_message_id(message_type).map_or_else( + || { + Err(format!( + "unsupported peer-wire message type: {message_type}" + )) + }, + |message_id| { + Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel { + message_id, + payload: payload.to_vec(), + })) + }, + ), + } +} + +/// Interprets a peer-wire message id and payload as a typed peer-wire message kind. +fn peer_wire_kind_from_message_id( + message_id: u8, + payload: &[u8], +) -> Result { + match message_id { + PEER_WIRE_CHOKE_ID => { + expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke) + } + PEER_WIRE_UNCHOKE_ID => { + expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke) + } + PEER_WIRE_INTERESTED_ID => { + expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested) + } + PEER_WIRE_NOT_INTERESTED_ID => expect_empty_payload("not_interested", payload) + .map(|()| PeerWireMessageKind::NotInterested), + PEER_WIRE_HAVE_ID => parse_have_payload(payload), + PEER_WIRE_BITFIELD_ID => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel { + bytes: payload.to_vec(), + })), + PEER_WIRE_REQUEST_ID => { + parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request) + } + PEER_WIRE_PIECE_ID => parse_piece_payload(payload).map(PeerWireMessageKind::Piece), + PEER_WIRE_CANCEL_ID => { + parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel) + } + PEER_WIRE_PORT_ID => parse_port_payload(payload), + PEER_WIRE_EXTENSION_ID => parse_extension_payload(payload), + _ => Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel { + message_id, + payload: payload.to_vec(), + })), + } +} + +/// Verifies that a peer-wire control payload is empty. +fn expect_empty_payload(name: &str, payload: &[u8]) -> Result<(), String> { + if payload.is_empty() { + Ok(()) + } else { + Err(format!( + "peer-wire {name} payload must be empty, got {} bytes", + payload.len() + )) + } +} + +/// Parses a `have` payload into its piece index variant. +fn parse_have_payload(payload: &[u8]) -> Result { + let piece_index = read_u32(payload, "have", 0)?; + Ok(PeerWireMessageKind::Have(piece_index)) +} + +/// Parses a `request` or `cancel` payload into block coordinates. +fn parse_block_request_payload( + name: &str, + payload: &[u8], +) -> Result { + if payload.len() != 12 { + return Err(format!( + "peer-wire {name} payload must be 12 bytes, got {}", + payload.len() + )); + } + Ok(PeerWireBlockRequestModel { + piece_index: read_u32(payload, name, 0)?, + block_offset: read_u32(payload, name, 4)?, + block_length: read_u32(payload, name, 8)?, + }) +} + +/// Parses a `piece` payload into block coordinates plus data. +fn parse_piece_payload(payload: &[u8]) -> Result { + if payload.len() < 8 { + return Err(format!( + "peer-wire piece payload must be at least 8 bytes, got {}", + payload.len() + )); + } + Ok(PeerWirePieceBlockModel { + piece_index: read_u32(payload, "piece", 0)?, + block_offset: read_u32(payload, "piece", 4)?, + block: payload[8..].to_vec(), + }) +} + +/// Parses a `port` payload into the corresponding peer-wire message variant. +fn parse_port_payload(payload: &[u8]) -> Result { + if payload.len() != 2 { + return Err(format!( + "peer-wire port payload must be 2 bytes, got {}", + payload.len() + )); + } + Ok(PeerWireMessageKind::Port(u16::from_be_bytes([ + payload[0], payload[1], + ]))) +} + +/// Parses an extension-protocol payload into the typed extension message variant. +fn parse_extension_payload(payload: &[u8]) -> Result { + let Some((&extension_message_id, rest)) = payload.split_first() else { + return Err("peer-wire extension payload must include extension message id".to_owned()); + }; + Ok(PeerWireMessageKind::Extension( + PeerWireExtensionMessageModel { + extension_message_id, + payload: rest.to_vec(), + }, + )) +} + +/// Parses a synthetic `unknown:` message-type label into a raw peer-wire id. +fn parse_unknown_message_id(message_type: &str) -> Option { + message_type + .strip_prefix("unknown:") + .and_then(|value| value.parse::().ok()) +} + +/// Encodes request or cancel block coordinates into peer-wire payload bytes. +fn encode_block_request_payload(request: &PeerWireBlockRequestModel) -> Vec { + let mut payload = Vec::with_capacity(12); + payload.extend_from_slice(&request.piece_index.to_be_bytes()); + payload.extend_from_slice(&request.block_offset.to_be_bytes()); + payload.extend_from_slice(&request.block_length.to_be_bytes()); + payload +} + +/// Encodes a piece block into peer-wire payload bytes. +fn encode_piece_payload(piece: &PeerWirePieceBlockModel) -> Vec { + let mut payload = Vec::with_capacity(8 + piece.block.len()); + payload.extend_from_slice(&piece.piece_index.to_be_bytes()); + payload.extend_from_slice(&piece.block_offset.to_be_bytes()); + payload.extend_from_slice(&piece.block); + payload +} + +/// Reads one big-endian `u32` from a peer-wire payload. +fn read_u32(payload: &[u8], name: &str, start: usize) -> Result { + let end = start + 4; + let bytes = payload + .get(start..end) + .ok_or_else(|| format!("peer-wire {name} payload truncated at byte offset {start}"))?; + Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/handshake.rs b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/handshake.rs new file mode 100644 index 0000000..002cf80 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/peer_wire/handshake.rs @@ -0,0 +1,122 @@ +use super::{ + PEER_WIRE_HANDSHAKE_PREFIX_LEN, PEER_WIRE_PROTOCOL_LEN, PEER_WIRE_PROTOCOL_NAME, + PeerWireHandshakeModel, +}; + +impl PeerWireHandshakeModel { + #[must_use] + /// Builds a handshake with all reserved bits cleared. + pub fn new(info_hash: [u8; 20], peer_id: [u8; 20]) -> Self { + Self { + reserved: [0; 8], + info_hash, + peer_id, + } + } + + /// Returns a handshake with the extension-protocol bit enabled. + #[must_use] + pub fn with_extension_protocol_enabled(mut self) -> Self { + self.reserved[5] |= 0x10; + self + } + + /// Returns a handshake with the DHT bit enabled. + #[must_use] + pub fn with_dht_enabled(mut self) -> Self { + self.reserved[7] |= 0x01; + self + } + + /// Serializes the handshake to its peer-wire byte representation. + /// + /// # Panics + /// + /// Panics if the fixed peer-wire protocol name no longer fits into a single-byte + /// length prefix. + #[must_use] + pub fn serialize(&self) -> Vec { + let mut bytes = Vec::with_capacity(PEER_WIRE_HANDSHAKE_PREFIX_LEN + 19); + bytes.push(PEER_WIRE_PROTOCOL_LEN); + bytes.extend_from_slice(PEER_WIRE_PROTOCOL_NAME.as_bytes()); + bytes.extend_from_slice(&self.reserved); + bytes.extend_from_slice(&self.info_hash); + bytes.extend_from_slice(&self.peer_id); + bytes + } + + /// Parses one complete peer-wire handshake from `input`. + /// + /// # Errors + /// + /// Returns an error when the frame is truncated, malformed, or contains trailing bytes. + pub fn parse(input: &[u8]) -> Result { + let (handshake, consumed) = Self::parse_prefix(input)?; + if consumed != input.len() { + return Err("trailing bytes after peer-wire handshake".to_owned()); + } + Ok(handshake) + } + + /// Parses a peer-wire handshake prefix and returns the consumed byte count. + /// + /// # Errors + /// + /// Returns an error when the frame is truncated or malformed. + pub fn parse_prefix(input: &[u8]) -> Result<(Self, usize), String> { + let Some(&protocol_len_byte) = input.first() else { + return Err("truncated peer-wire handshake: missing protocol length".to_owned()); + }; + let protocol_len = usize::from(protocol_len_byte); + let total_len = PEER_WIRE_HANDSHAKE_PREFIX_LEN + protocol_len; + if input.len() < total_len { + return Err(format!( + "truncated peer-wire handshake: expected {total_len} bytes, got {}", + input.len() + )); + } + let protocol = &input[1..=protocol_len]; + if protocol_len != PEER_WIRE_PROTOCOL_NAME.len() { + return Err(format!( + "invalid peer-wire protocol length: expected {}, got {protocol_len}", + PEER_WIRE_PROTOCOL_NAME.len() + )); + } + if protocol != PEER_WIRE_PROTOCOL_NAME.as_bytes() { + return Err("invalid peer-wire protocol header".to_owned()); + } + + let reserved_start = 1 + protocol_len; + let mut reserved = [0_u8; 8]; + reserved.copy_from_slice(&input[reserved_start..reserved_start + 8]); + + let info_hash_start = reserved_start + 8; + let mut info_hash = [0_u8; 20]; + info_hash.copy_from_slice(&input[info_hash_start..info_hash_start + 20]); + + let peer_id_start = info_hash_start + 20; + let mut peer_id = [0_u8; 20]; + peer_id.copy_from_slice(&input[peer_id_start..peer_id_start + 20]); + + Ok(( + Self { + reserved, + info_hash, + peer_id, + }, + total_len, + )) + } + + #[must_use] + /// Returns whether the extension-protocol reserved bit is enabled. + pub fn extension_protocol_enabled(&self) -> bool { + self.reserved[5] & 0x10 != 0 + } + + #[must_use] + /// Returns whether the DHT reserved bit is enabled. + pub fn dht_enabled(&self) -> bool { + self.reserved[7] & 0x01 != 0 + } +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/tests.rs b/crates/aria2-rust-pro-protocol/src/torrent/tests.rs new file mode 100644 index 0000000..6735530 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/tests.rs @@ -0,0 +1,721 @@ +use std::collections::BTreeMap; + +use super::{ + DhtAnnouncePeerQueryModel, DhtCompactNodeModel, DhtFindNodeResponseModel, + DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtQueryModel, DhtResponseModel, + PeerWireBitfieldModel, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel, + PeerWireExtensionMessageModel, PeerWireFrameHeaderModel, PeerWireHandshakeModel, + PeerWireMessageKind, PeerWireMessageModel, PeerWireMetadataMessageModel, + PeerWireMetadataMessageType, PeerWirePieceBlockModel, PeerWireUnknownMessageModel, + TorrentMessageModel, decode_compact_dht_nodes, encode_compact_dht_nodes, + parse_torrent_bootstrap, parse_torrent_metadata, +}; + +#[test] +fn dht_ping_query_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::ping_query(b"aa".to_vec(), vec![0x11; 20]); + assert_eq!(message.method(), Some("ping")); + assert!(message.is_query()); + + let encoded = message.to_bencode_bytes(); + let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse"); + + assert_eq!(decoded, message); + assert_eq!(decoded.transaction_id(), b"aa"); +} + +#[test] +fn dht_ping_response_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::ping_response(b"pr".to_vec(), vec![0x44; 20]); + let encoded = message.to_bencode_bytes(); + let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse"); + + assert_eq!(decoded, message); + assert!(matches!( + decoded.body, + DhtMessageBody::Response(DhtResponseModel::Ping(_)) + )); +} + +#[test] +fn dht_find_node_query_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::find_node_query(b"fn".to_vec(), vec![0x22; 20], vec![0x33; 20]); + assert_eq!(message.method(), Some("find_node")); + + let encoded = message.to_bencode_bytes(); + let decoded = + DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded find_node should parse"); + + assert_eq!(decoded, message); +} + +#[test] +fn dht_announce_peer_query_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::announce_peer_query( + b"ap".to_vec(), + vec![0x11; 20], + vec![0x22; 20], + 6881, + b"tok".to_vec(), + true, + ); + assert_eq!(message.method(), Some("announce_peer")); + + let encoded = message.to_bencode_bytes(); + let decoded = + DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded announce_peer should parse"); + + assert_eq!(decoded, message); + assert!(matches!( + decoded.body, + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel { + implied_port: true, + port: 6881, + .. + })) + )); +} + +#[test] +fn dht_get_peers_query_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::get_peers_query(b"gp".to_vec(), vec![0x22; 20], vec![0x33; 20]); + assert_eq!(message.method(), Some("get_peers")); + assert!(matches!( + &message.body, + DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) + )); + + let encoded = message.to_bencode_bytes(); + let decoded = + DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded get_peers should parse"); + assert_eq!(decoded, message); +} + +#[test] +fn dht_get_peers_response_roundtrip_preserves_nodes_values_and_token() { + let message = DhtMessageModel::get_peers_response( + b"r1".to_vec(), + vec![0x44; 20], + Some(b"tok".to_vec()), + Some(vec![0xaa, 0xbb, 0xcc, 0xdd]), + vec![vec![127, 0, 0, 1, 0x1a, 0xe1]], + ); + let encoded = message.to_bencode_bytes(); + let decoded = + DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded response should parse"); + + assert_eq!(decoded, message); + assert!(matches!( + decoded.body, + DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel { + token: Some(_), + nodes: Some(_), + .. + })) + )); +} + +#[test] +fn dht_find_node_response_roundtrip_preserves_compact_nodes() { + let nodes = vec![ + DhtCompactNodeModel { + node_id: [0x11; 20], + address: [127, 0, 0, 1], + port: 6881, + }, + DhtCompactNodeModel { + node_id: [0x22; 20], + address: [192, 0, 2, 1], + port: 51413, + }, + ]; + let message = DhtMessageModel::find_node_response(b"fnr".to_vec(), vec![0x33; 20], nodes); + let encoded = message.to_bencode_bytes(); + let decoded = DhtMessageModel::from_bencode_bytes(&encoded) + .expect("encoded find_node response should parse"); + + assert_eq!(decoded, message); + assert!(matches!( + decoded.body, + DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel { + nodes: ref parsed_nodes, + .. + })) if parsed_nodes.len() == 2 + )); +} + +#[test] +fn compact_dht_node_codec_roundtrip_serializes_bytes_in_network_order() { + let node = DhtCompactNodeModel { + node_id: [0x7f; 20], + address: [198, 51, 100, 7], + port: 51413, + }; + let bytes = encode_compact_dht_nodes(std::slice::from_ref(&node)); + assert_eq!(bytes.len(), 26); + let decoded = decode_compact_dht_nodes(&bytes).expect("compact node codec should parse"); + assert_eq!(decoded, vec![node]); +} + +#[test] +fn dht_error_roundtrip_serializes_and_parses() { + let message = DhtMessageModel::error_response(b"e1".to_vec(), 203, "protocol error"); + let encoded = message.to_bencode_bytes(); + let decoded = + DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded error should parse"); + + assert_eq!(decoded, message); + assert_eq!(decoded.method(), None); +} + +#[test] +fn dht_parse_rejects_unsupported_query_method() { + let payload = b"d1:ad2:id20:aaaaaaaaaaaaaaaaaaaae1:q4:find1:t2:aa1:y1:qe"; + let error = + DhtMessageModel::from_bencode_bytes(payload).expect_err("unsupported method should fail"); + assert!(error.contains("unsupported dht query method")); +} + +#[test] +fn parses_single_file_torrent_metadata() { + let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod4:name10:ubuntu.iso12:piece lengthi16384e6:lengthi32768e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee"; + let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse"); + assert_eq!(metadata.info.name, "ubuntu.iso"); + assert_eq!(metadata.info.piece_length, 16384); + assert_eq!(metadata.info.files.len(), 1); + assert_eq!( + metadata.announce.as_deref(), + Some("http://tracker.example.org/announce") + ); + assert_eq!(metadata.trackers.len(), 1); + assert_eq!(metadata.pieces.len(), 2); + assert_eq!( + metadata + .info + .hash + .as_ref() + .map(|hash| hash.info_hash_hex.len()), + Some(40) + ); +} + +#[test] +fn parses_announce_list_tiers_with_stable_tier_indices() { + let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceel30:udp://tier2-a.example.org:6969ee4:infod6:lengthi4096e4:name8:mini.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee"; + let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse"); + + assert_eq!(metadata.trackers.len(), 4); + assert_eq!( + metadata.trackers[0].url, + "http://tracker.example.org/announce" + ); + assert_eq!(metadata.trackers[0].tier, Some(0)); + + assert_eq!(metadata.trackers[1].url, "udp://tier1-a.example.org:6969"); + assert_eq!(metadata.trackers[1].tier, Some(1)); + assert_eq!( + metadata.trackers[2].url, + "http://tier1-b.example.org/announce" + ); + assert_eq!(metadata.trackers[2].tier, Some(1)); + + assert_eq!(metadata.trackers[3].url, "udp://tier2-a.example.org:6969"); + assert_eq!(metadata.trackers[3].tier, Some(2)); +} + +#[test] +fn parses_multi_file_paths_and_piece_offsets() { + let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod5:filesld6:lengthi123e4:pathl4:dir110:file-a.bineed6:lengthi200e4:pathl4:dir24:subd10:file-b.bineed6:lengthi45e4:pathl10:readme.txteee4:name6:bundle12:piece lengthi128e6:pieces60:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbccccccccccccccccccccee"; + let metadata = parse_torrent_metadata(torrent).expect("multi-file torrent should parse"); + + assert_eq!(metadata.info.files.len(), 3); + assert_eq!(metadata.info.files[0].path, "dir1/file-a.bin"); + assert_eq!(metadata.info.files[0].length, 123); + assert_eq!(metadata.info.files[0].piece_offset, Some(0)); + + assert_eq!(metadata.info.files[1].path, "dir2/subd/file-b.bin"); + assert_eq!(metadata.info.files[1].length, 200); + assert_eq!(metadata.info.files[1].piece_offset, Some(123)); + + assert_eq!(metadata.info.files[2].path, "readme.txt"); + assert_eq!(metadata.info.files[2].length, 45); + assert_eq!(metadata.info.files[2].piece_offset, Some(323)); + + assert_eq!(metadata.total_length(), 368); +} + +#[test] +fn parses_dht_nodes_from_nodes_list_when_present() { + let torrent = br"d8:announce35:http://tracker.example.org/announce5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee"; + let metadata = parse_torrent_metadata(torrent).expect("torrent with nodes should parse"); + + assert_eq!(metadata.dht_nodes.len(), 2); + assert_eq!(metadata.dht_nodes[0], "router.bittorrent:6881"); + assert_eq!(metadata.dht_nodes[1], "node.local.lan:51413"); +} + +#[test] +fn torrent_bootstrap_exposes_magnet_info_hash_and_dht_models() { + let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceee5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee"; + let bootstrap = parse_torrent_bootstrap(torrent).expect("torrent bootstrap should parse"); + + assert_eq!(bootstrap.metadata.info.name, "node.iso"); + assert_eq!( + bootstrap.info_hash_hex, + bootstrap + .metadata + .info + .hash + .as_ref() + .expect("torrent hash should exist") + .info_hash_hex + ); + assert_eq!(bootstrap.info_hash_bytes.len(), 20); + assert_eq!( + bootstrap.magnet.trackers, + vec![ + "http://tracker.example.org/announce".to_owned(), + "udp://tier1-a.example.org:6969".to_owned(), + "http://tier1-b.example.org/announce".to_owned(), + ] + ); + assert_eq!(bootstrap.dht_nodes[0].to_spec(), "router.bittorrent:6881"); + assert_eq!(bootstrap.dht_nodes[1].to_spec(), "node.local.lan:51413"); +} + +#[test] +fn dht_get_peers_response_helpers_decode_peer_values_and_nodes() { + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x11; 20], + Some(b"tok".to_vec()), + Some(vec![ + 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, + 0xf0, 0x00, 0x01, 0x02, 0x03, 0x04, 192, 0, 2, 10, 0x1a, 0xe1, + ]), + vec![vec![198, 51, 100, 9, 0xc8, 0xd5]], + ); + + let DhtMessageBody::Response(DhtResponseModel::GetPeers(payload)) = response.body else { + panic!("expected get_peers response"); + }; + + let peers = payload + .peer_contacts() + .expect("compact peers should decode"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].ip, "198.51.100.9"); + assert_eq!(peers[0].port, 51413); + + let nodes = payload.dht_nodes().expect("compact nodes should decode"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].node_id, "102030405060708090a0b0c0d0e0f00001020304"); + assert_eq!(nodes[0].to_spec(), "192.0.2.10:6881"); +} + +#[test] +fn peer_wire_handshake_roundtrip_preserves_reserved_bits_and_ids() { + let handshake = PeerWireHandshakeModel { + reserved: [0, 0, 0, 0, 0, 0x10, 0, 0x01], + info_hash: [0x11; 20], + peer_id: [0x22; 20], + }; + + let bytes = handshake.serialize(); + let parsed = + PeerWireHandshakeModel::parse(&bytes).expect("handshake bytes should parse cleanly"); + + assert_eq!(parsed, handshake); + assert!(parsed.extension_protocol_enabled()); + assert!(parsed.dht_enabled()); +} + +#[test] +fn peer_wire_handshake_rejects_truncated_and_invalid_protocol_bytes() { + let truncated = vec![19, b'B', b'i']; + assert!( + PeerWireHandshakeModel::parse(&truncated) + .expect_err("truncated handshake should fail") + .contains("truncated") + ); + + let mut invalid = PeerWireHandshakeModel { + reserved: [0; 8], + info_hash: [1; 20], + peer_id: [2; 20], + } + .serialize(); + invalid[1] = b'X'; + assert!( + PeerWireHandshakeModel::parse(&invalid) + .expect_err("invalid protocol name should fail") + .contains("protocol") + ); +} + +#[test] +fn peer_wire_keepalive_roundtrip_uses_zero_length_frame() { + let message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::KeepAlive); + let bytes = message + .serialize_peer_wire_frame() + .expect("keepalive should serialize"); + + assert_eq!(bytes, vec![0, 0, 0, 0]); + + let parsed = + TorrentMessageModel::parse_peer_wire_frame_exact(&bytes).expect("keepalive should parse"); + assert_eq!(parsed, message); + assert_eq!(parsed.peer_wire_kind(), Ok(PeerWireMessageKind::KeepAlive)); +} + +#[test] +fn peer_wire_inspect_frame_reports_message_id_payload_len_and_consumed_bytes() { + let keepalive_header = TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 0]) + .expect("keepalive frame header should parse"); + assert_eq!( + keepalive_header, + ( + PeerWireFrameHeaderModel { + message_id: None, + payload_len: 0, + }, + 4 + ) + ); + + let request = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request( + PeerWireBlockRequestModel { + piece_index: 1, + block_offset: 2, + block_length: 16_384, + }, + )); + let mut request_frame = request + .serialize_peer_wire_frame() + .expect("request frame should serialize"); + request_frame.extend_from_slice(&[0x99, 0x88, 0x77]); + + let request_header = TorrentMessageModel::inspect_peer_wire_frame(&request_frame) + .expect("request frame header should parse"); + assert_eq!( + request_header, + ( + PeerWireFrameHeaderModel { + message_id: Some(6), + payload_len: 12, + }, + 17 + ) + ); +} + +#[test] +fn peer_wire_inspect_frame_rejects_truncated_prefix_or_payload() { + assert!( + TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0]) + .expect_err("missing length prefix should fail") + .contains("missing length prefix") + ); + assert!( + TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 1]) + .expect_err("missing message id should fail") + .contains("truncated") + ); +} + +#[test] +fn peer_wire_control_messages_roundtrip_through_wrapper_surface() { + let cases = [ + PeerWireMessageKind::Choke, + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Interested, + PeerWireMessageKind::NotInterested, + ]; + + for kind in cases { + let wire = PeerWireMessageModel::new( + Some([0x44; 20]), + TorrentMessageModel::from_peer_wire_kind(kind.clone()), + ); + let bytes = wire + .serialize_frame() + .expect("control peer-wire frame should serialize"); + let parsed = PeerWireMessageModel::parse_frame_exact(&bytes) + .expect("control peer-wire frame should parse"); + + assert_eq!(parsed.peer_id, None); + assert_eq!(parsed.message.peer_wire_kind(), Ok(kind)); + } +} + +#[test] +fn peer_wire_bitfield_helpers_pack_bits_msb_first() { + let flags = [ + true, false, true, true, false, false, false, true, true, false, + ]; + let bitfield = PeerWireBitfieldModel::from_piece_flags(&flags); + + assert_eq!(bitfield.bytes, vec![0b1011_0001, 0b1000_0000]); + assert_eq!(bitfield.piece_capacity(), 16); + assert!(bitfield.has_piece(0)); + assert!(bitfield.has_piece(8)); + assert!(!bitfield.has_piece(9)); + assert_eq!(bitfield.to_piece_flags(flags.len()), flags); + + let message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Bitfield(bitfield.clone())); + let roundtrip = TorrentMessageModel::parse_peer_wire_frame_exact( + &message + .serialize_peer_wire_frame() + .expect("bitfield should serialize"), + ) + .expect("bitfield should roundtrip"); + assert_eq!( + roundtrip.peer_wire_kind(), + Ok(PeerWireMessageKind::Bitfield(bitfield)) + ); +} + +#[test] +fn peer_wire_have_request_and_cancel_roundtrip() { + let have = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Have(77)); + let request = PeerWireBlockRequestModel { + piece_index: 7, + block_offset: 16_384, + block_length: 4_096, + }; + + let request_message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(request)); + let cancel_message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Cancel(request)); + + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &have + .serialize_peer_wire_frame() + .expect("have should serialize"), + ) + .expect("have should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Have(77)) + ); + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &request_message + .serialize_peer_wire_frame() + .expect("request should serialize"), + ) + .expect("request should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Request(request)) + ); + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &cancel_message + .serialize_peer_wire_frame() + .expect("cancel should serialize"), + ) + .expect("cancel should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Cancel(request)) + ); +} + +#[test] +fn peer_wire_piece_port_extension_and_unknown_roundtrip() { + let piece = PeerWirePieceBlockModel { + piece_index: 5, + block_offset: 32_768, + block: b"block-data".to_vec(), + }; + let extension = PeerWireExtensionMessageModel { + extension_message_id: 3, + payload: b"ut_metadata".to_vec(), + }; + let unknown = PeerWireUnknownMessageModel { + message_id: 99, + payload: vec![9, 8, 7, 6], + }; + + let piece_message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Piece(piece.clone())); + let port_message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Port(51413)); + let extension_message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(extension.clone())); + let unknown_message = + TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Unknown(unknown.clone())); + + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &piece_message + .serialize_peer_wire_frame() + .expect("piece should serialize"), + ) + .expect("piece should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Piece(piece)) + ); + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &port_message + .serialize_peer_wire_frame() + .expect("port should serialize"), + ) + .expect("port should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Port(51413)) + ); + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &extension_message + .serialize_peer_wire_frame() + .expect("extension should serialize"), + ) + .expect("extension should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Extension(extension)) + ); + assert_eq!( + TorrentMessageModel::parse_peer_wire_frame_exact( + &unknown_message + .serialize_peer_wire_frame() + .expect("unknown should serialize"), + ) + .expect("unknown should parse") + .peer_wire_kind(), + Ok(PeerWireMessageKind::Unknown(unknown)) + ); +} + +#[test] +fn extension_handshake_and_ut_metadata_messages_roundtrip() { + let handshake = PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([ + ("ut_metadata".to_owned(), 3_u8), + ("ut_pex".to_owned(), 1_u8), + ]), + client_name: Some("aria2-rust-pro".to_owned()), + metadata_size: Some(32_768), + request_queue: Some(32), + }; + + let handshake_message = handshake.to_peer_wire_message(); + assert_eq!(handshake_message.extension_message_id, 0); + let parsed_handshake = + PeerWireExtensionHandshakeModel::from_peer_wire_message(&handshake_message) + .expect("extended handshake should parse"); + assert_eq!(parsed_handshake, handshake); + assert_eq!(parsed_handshake.ut_metadata_id(), Some(3)); + assert_eq!(parsed_handshake.metadata_piece_count(), Some(2)); + + let request = PeerWireMetadataMessageModel::request(7); + let parsed_request = + PeerWireMetadataMessageModel::from_peer_wire_message(&request.to_peer_wire_message(3), 3) + .expect("metadata request should parse"); + assert_eq!( + parsed_request.message_type, + PeerWireMetadataMessageType::Request + ); + assert_eq!(parsed_request.piece, 7); + assert!(parsed_request.payload.is_empty()); + + let data = PeerWireMetadataMessageModel::data(0, 11, b"piece-bytes".to_vec()); + let parsed_data = + PeerWireMetadataMessageModel::from_peer_wire_message(&data.to_peer_wire_message(3), 3) + .expect("metadata data should parse"); + assert_eq!(parsed_data.message_type, PeerWireMetadataMessageType::Data); + assert_eq!(parsed_data.piece, 0); + assert_eq!(parsed_data.total_size, Some(11)); + assert_eq!(parsed_data.payload, b"piece-bytes"); + + let reject = PeerWireMetadataMessageModel::reject(4); + let parsed_reject = + PeerWireMetadataMessageModel::from_peer_wire_message(&reject.to_peer_wire_message(3), 3) + .expect("metadata reject should parse"); + assert_eq!( + parsed_reject.message_type, + PeerWireMetadataMessageType::Reject + ); + assert_eq!(parsed_reject.piece, 4); +} + +#[test] +fn extended_handshake_treats_zero_ut_metadata_id_as_disabled() { + let handshake = PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([ + ("ut_metadata".to_owned(), 0_u8), + ("ut_pex".to_owned(), 1_u8), + ]), + client_name: Some("aria2-rust-pro".to_owned()), + metadata_size: Some(16_384), + request_queue: Some(8), + }; + + let parsed = PeerWireExtensionHandshakeModel::from_bencode_bytes(&handshake.to_bencode_bytes()) + .expect("extended handshake should parse"); + assert_eq!(parsed.extensions.get("ut_metadata"), Some(&0)); + assert_eq!(parsed.ut_metadata_id(), None); + assert_eq!(parsed.metadata_piece_count(), Some(1)); +} + +#[test] +fn ut_metadata_data_messages_reject_out_of_range_piece_indexes() { + let invalid = b"d8:msg_typei1e5:piecei2e10:total_sizei16384eepayload".to_vec(); + assert!( + PeerWireMetadataMessageModel::from_bencode_bytes(&invalid) + .expect_err("piece index beyond metadata size should fail") + .contains("piece") + ); +} + +#[test] +fn ut_metadata_data_messages_reject_payload_lengths_that_do_not_match_piece_geometry() { + let invalid = b"d8:msg_typei1e5:piecei0e10:total_sizei16385ee".to_vec(); + assert!( + PeerWireMetadataMessageModel::from_bencode_bytes(&invalid) + .expect_err("empty payload for non-empty piece should fail") + .contains("payload") + ); +} + +#[test] +fn ut_metadata_request_and_reject_messages_reject_total_size_fields() { + let request_with_total_size = b"d8:msg_typei0e5:piecei0e10:total_sizei16384ee".to_vec(); + assert!( + PeerWireMetadataMessageModel::from_bencode_bytes(&request_with_total_size) + .expect_err("request total_size should fail") + .contains("total_size") + ); + + let reject_with_total_size = b"d8:msg_typei2e5:piecei1e10:total_sizei16384ee".to_vec(); + assert!( + PeerWireMetadataMessageModel::from_bencode_bytes(&reject_with_total_size) + .expect_err("reject total_size should fail") + .contains("total_size") + ); +} + +#[test] +fn peer_wire_parse_rejects_truncated_and_invalid_payload_shapes() { + let truncated = [0, 0, 0, 13, 6, 0, 0, 0, 1, 0, 0]; + assert!( + TorrentMessageModel::parse_peer_wire_frame_exact(&truncated) + .expect_err("truncated request frame should fail") + .contains("truncated") + ); + + let invalid_have = [0, 0, 0, 4, 4, 0, 0, 0]; + assert!( + TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_have) + .expect_err("invalid have frame should fail") + .contains("have") + ); + + let invalid_extension = [0, 0, 0, 1, 20]; + assert!( + TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_extension) + .expect_err("extension frame missing ext id should fail") + .contains("extension") + ); +} diff --git a/crates/aria2-rust-pro-protocol/src/torrent/utils.rs b/crates/aria2-rust-pro-protocol/src/torrent/utils.rs new file mode 100644 index 0000000..4bdf106 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/torrent/utils.rs @@ -0,0 +1,69 @@ +use super::bencode::BencodeValue; + +/// Decodes a 40-character hexadecimal string into a fixed-width 20-byte array. +pub(super) fn decode_hex_20_array(input: &str) -> Result<[u8; 20], String> { + if input.len() != 40 { + return Err(format!( + "expected 40 hex characters for 20-byte info-hash, got {}", + input.len() + )); + } + let mut out = [0_u8; 20]; + for (index, chunk) in input.as_bytes().chunks_exact(2).enumerate() { + let hi = decode_hex_nibble(chunk[0]) + .ok_or_else(|| "info-hash contains non-hex characters".to_owned())?; + let lo = decode_hex_nibble(chunk[1]) + .ok_or_else(|| "info-hash contains non-hex characters".to_owned())?; + out[index] = (hi << 4) | lo; + } + Ok(out) +} + +/// Decodes one ASCII hexadecimal nibble. +const fn decode_hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Decodes torrent byte strings into owned lossy UTF-8 text. +pub(super) fn bytes_to_string(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Converts one torrent bencode value into human-readable string form. +pub(super) fn value_to_string(value: &BencodeValue) -> String { + match value { + BencodeValue::Bytes(bytes) => bytes_to_string(bytes), + BencodeValue::Int(value) => value.to_string(), + BencodeValue::List(values) => values + .iter() + .map(value_to_string) + .collect::>() + .join(","), + BencodeValue::Dict(map) => map + .iter() + .map(|(key, value)| format!("{key}={}", value_to_string(value))) + .collect::>() + .join("&"), + } +} + +/// Hex-encodes raw torrent bytes using lowercase hexadecimal. +pub(super) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} + +/// Saturates an integer-like torrent length field into `u64`. +pub(super) fn i64_to_u64(value: i64) -> u64 { + u64::try_from(value.max(0)).unwrap_or(u64::MAX) +} diff --git a/crates/aria2-rust-pro-protocol/src/tracker.rs b/crates/aria2-rust-pro-protocol/src/tracker.rs new file mode 100644 index 0000000..d2dbdc7 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker.rs @@ -0,0 +1,47 @@ +//! Tracker parsing, scrape models, and tracker transport implementations. +#![forbid(unsafe_code)] + +pub(super) use std::{ + collections::BTreeMap, + fmt::{Display, Formatter}, + sync::atomic::{AtomicU32, Ordering}, +}; + +pub(super) use reqwest::blocking::Client; + +pub(super) use crate::{ + torrent::{DhtMessageModel, TorrentPeerModel}, + transport::{TransportError, TransportErrorKind}, +}; + +/// Tracker parsing and validation error model. +mod error; +/// Bencode parsing plus tracker wire-format normalization helpers. +mod parsing; +/// Shared tracker and DHT request-response protocol models. +mod request_response; +/// Live reqwest-backed HTTP tracker transport. +mod reqwest_transport; +/// UDP tracker message types and transport helpers. +mod udp; + +pub use self::error::TrackerParseError; +pub use self::request_response::{ + DhtNodeModel, DhtTransport, TrackerPeerListModel, TrackerRequestModel, TrackerResponseModel, + TrackerScrapeFileModel, TrackerScrapeModel, TrackerTransport, +}; +pub use self::reqwest_transport::ReqwestTrackerTransport; +pub use self::udp::{ + UDP_TRACKER_PROTOCOL_ID, UdpTrackerAction, UdpTrackerAnnounceEvent, UdpTrackerAnnounceRequest, + UdpTrackerAnnounceResponse, UdpTrackerConnectRequest, UdpTrackerConnectResponse, + UdpTrackerResponseHeader, UdpTrackerScrapeRequest, UdpTrackerScrapeResponse, + UdpTrackerScrapeStats, UdpTrackerTransactionId, +}; + +#[cfg(test)] +fn hex_encode(bytes: &[u8]) -> String { + parsing::hex_encode(bytes) +} + +#[cfg(test)] +mod tracker_tests; diff --git a/crates/aria2-rust-pro-protocol/src/tracker/error.rs b/crates/aria2-rust-pro-protocol/src/tracker/error.rs new file mode 100644 index 0000000..d327464 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/error.rs @@ -0,0 +1,44 @@ +use super::{Display, Formatter}; + +/// Errors raised while parsing tracker announce, scrape, or UDP payloads. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TrackerParseError { + /// The tracker bencode payload was malformed. + InvalidBencode(String), + /// A required field was missing from the payload. + MissingField(&'static str), + /// A hex-encoded value was malformed. + InvalidHex(String), + /// A peer entry was malformed. + InvalidPeer(String), + /// A UDP tracker packet was malformed. + InvalidUdpPacket(String), + /// A UDP tracker action code was unknown. + InvalidUdpAction(u32), + /// The response transaction identifier differed from the request identifier. + TransactionIdMismatch { + /// Transaction identifier the caller expected to receive. + expected: u32, + /// Transaction identifier that was actually received. + actual: u32, + }, +} + +impl Display for TrackerParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidBencode(reason) => write!(f, "invalid tracker bencode: {reason}"), + Self::MissingField(field) => write!(f, "missing tracker field: {field}"), + Self::InvalidHex(value) => write!(f, "invalid hex string: {value}"), + Self::InvalidPeer(reason) => write!(f, "invalid peer entry: {reason}"), + Self::InvalidUdpPacket(reason) => write!(f, "invalid udp tracker packet: {reason}"), + Self::InvalidUdpAction(action) => write!(f, "invalid udp tracker action id: {action}"), + Self::TransactionIdMismatch { expected, actual } => write!( + f, + "udp tracker transaction id mismatch: expected {expected}, got {actual}" + ), + } + } +} + +impl std::error::Error for TrackerParseError {} diff --git a/crates/aria2-rust-pro-protocol/src/tracker/parsing.rs b/crates/aria2-rust-pro-protocol/src/tracker/parsing.rs new file mode 100644 index 0000000..f1e701d --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/parsing.rs @@ -0,0 +1,491 @@ +use super::{BTreeMap, TorrentPeerModel}; +use crate::tracker::{TrackerParseError, TrackerScrapeFileModel, TrackerScrapeModel}; + +#[derive(Clone, Debug, PartialEq, Eq)] +/// Internal bencode value representation used while parsing tracker payloads. +pub(super) enum BencodeValue { + /// Signed integer literal. + Int(i64), + /// Raw byte string payload. + Bytes(Vec), + /// Ordered list of nested bencode values. + List(Vec), + /// Dictionary keyed by raw tracker bytes. + Dict(BencodeDict), +} + +/// Internal tracker bencode dictionary keyed by raw byte strings. +pub(super) type BencodeDict = BTreeMap, BencodeValue>; + +/// Parses a tracker bencode payload whose root value must be a dictionary. +pub(super) fn parse_bencode(input: &[u8]) -> Result { + let (value, next) = parse_value(input, 0)?; + if next != input.len() { + return Err(TrackerParseError::InvalidBencode( + "trailing data after root value".to_owned(), + )); + } + match value { + BencodeValue::Dict(map) => Ok(map), + _ => Err(TrackerParseError::InvalidBencode( + "tracker response root must be a dictionary".to_owned(), + )), + } +} + +/// Parses the `peers` field from a tracker response dictionary. +pub(super) fn parse_peer_list( + root: &BencodeDict, +) -> Result, TrackerParseError> { + match root.get(b"peers".as_slice()) { + Some(BencodeValue::Bytes(bytes)) => parse_compact_peers(bytes), + Some(BencodeValue::List(entries)) => entries + .iter() + .map(parse_peer_dict) + .collect::, _>>(), + Some(_) => Err(TrackerParseError::InvalidBencode( + "peers must be bytes or list".to_owned(), + )), + None => Ok(Vec::new()), + } +} + +/// Parses a compact IPv4 peer blob into tracker peer models. +pub(super) fn parse_compact_peers_ipv4( + bytes: &[u8], +) -> Result, TrackerParseError> { + parse_compact_peers_with_stride(bytes, 6, |chunk| { + let ip = format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]); + let port = u16::from_be_bytes([chunk[4], chunk[5]]); + TorrentPeerModel { + peer_id: None, + ip, + port, + client_name: None, + interested: false, + choked: false, + } + }) +} + +/// Extracts optional scrape metadata from an announce-style tracker dictionary. +pub(super) fn parse_scrape_section(root: &BencodeDict) -> Option { + parse_scrape_section_from_root(root) +} + +/// Extracts scrape metadata from a `files` scrape dictionary when present. +pub(super) fn parse_scrape_section_from_root(root: &BencodeDict) -> Option { + let files = match root.get(b"files".as_slice()) { + Some(BencodeValue::Dict(files)) => files + .iter() + .filter_map(|(info_hash, value)| match value { + BencodeValue::Dict(stats) => Some(TrackerScrapeFileModel { + info_hash: hex_encode(info_hash), + complete: dict_get_int(stats, "complete").map(i64_to_u32), + downloaded: dict_get_int(stats, "downloaded").map(i64_to_u32), + incomplete: dict_get_int(stats, "incomplete").map(i64_to_u32), + }), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }; + + if files.is_empty() { + let complete = dict_get_int(root, "complete").map(i64_to_u32); + let downloaded = dict_get_int(root, "downloaded").map(i64_to_u32); + let incomplete = dict_get_int(root, "incomplete").map(i64_to_u32); + if complete.is_none() && downloaded.is_none() && incomplete.is_none() { + return None; + } + return Some(TrackerScrapeModel { + complete, + downloaded, + incomplete, + files, + }); + } + + Some(TrackerScrapeModel { + complete: dict_get_int(root, "complete").map(i64_to_u32), + downloaded: dict_get_int(root, "downloaded").map(i64_to_u32), + incomplete: dict_get_int(root, "incomplete").map(i64_to_u32), + files, + }) +} + +/// Looks up a raw byte-string field inside a tracker bencode dictionary. +pub(super) fn dict_get_bytes<'a>(dict: &'a BencodeDict, key: &str) -> Option<&'a [u8]> { + match dict.get(key.as_bytes()) { + Some(BencodeValue::Bytes(bytes)) => Some(bytes.as_slice()), + _ => None, + } +} + +/// Looks up an integer field inside a tracker bencode dictionary. +pub(super) fn dict_get_int(dict: &BencodeDict, key: &str) -> Option { + match dict.get(key.as_bytes()) { + Some(BencodeValue::Int(value)) => Some(*value), + _ => None, + } +} + +/// Decodes tracker bytes into owned lossy UTF-8 text. +pub(super) fn bytes_to_string(value: &[u8]) -> String { + String::from_utf8_lossy(value).into_owned() +} + +/// Hex-encodes a raw tracker info-hash or peer-id byte slice. +pub(super) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} + +/// Derives the scrape URL from an announce URL using the conventional path rewrite. +pub(super) fn tracker_scrape_url(announce_url: &str) -> String { + if let Some(stripped) = announce_url.strip_suffix("announce.php") { + return format!("{stripped}scrape.php"); + } + if let Some(stripped) = announce_url.strip_suffix("announce") { + return format!("{stripped}scrape"); + } + announce_url.to_owned() +} + +/// Parses a `host:port` or `[ipv6]:port` endpoint spec into its address and port components. +pub(super) fn parse_endpoint_spec( + raw: &str, + label: &'static str, +) -> Result<(String, u16), TrackerParseError> { + if let Some(rest) = raw.strip_prefix('[') { + let (address, port_raw) = rest.split_once("]:").ok_or_else(|| { + TrackerParseError::InvalidPeer(format!( + "{label} entry must use [ipv6]:port format when brackets are present" + )) + })?; + let port = port_raw.parse::().map_err(|_| { + TrackerParseError::InvalidPeer(format!("{label} port must be a valid u16")) + })?; + return Ok((address.to_owned(), port)); + } + + let (address, port_raw) = raw.rsplit_once(':').ok_or_else(|| { + TrackerParseError::InvalidPeer(format!("{label} entry must use host:port format")) + })?; + if address.is_empty() { + return Err(TrackerParseError::InvalidPeer(format!( + "{label} address must not be empty" + ))); + } + let port = port_raw + .parse::() + .map_err(|_| TrackerParseError::InvalidPeer(format!("{label} port must be a valid u16")))?; + Ok((address.to_owned(), port)) +} + +/// Formats an endpoint spec while preserving bracketed IPv6 output. +pub(super) fn format_endpoint_spec(address: &str, port: u16) -> String { + if address.contains(':') && !address.starts_with('[') && !address.ends_with(']') { + format!("[{address}]:{port}") + } else { + format!("{address}:{port}") + } +} + +/// Builds the final HTTP tracker announce URL and query string. +pub(super) fn build_url( + base_url: &str, + query_pairs: &[(String, String)], +) -> Result { + let mut out = String::from(base_url); + if !out.contains('?') { + out.push('?'); + } else if !out.ends_with('?') && !out.ends_with('&') { + out.push('&'); + } + for (index, (key, value)) in query_pairs.iter().enumerate() { + if index > 0 { + out.push('&'); + } + out.push_str(key); + out.push('='); + out.push_str(&percent_encode_tracker_value(key, value)?); + } + Ok(out) +} + +/// Decodes a 40-character hex string into a 20-byte tracker field. +pub(super) fn decode_hex_20(input: &str) -> Result<[u8; 20], TrackerParseError> { + let text = input.trim(); + if text.len() != 40 { + return Err(TrackerParseError::InvalidHex(input.to_owned())); + } + let mut out = [0_u8; 20]; + let bytes = text.as_bytes(); + for (index, slot) in out.iter_mut().enumerate() { + let hi = hex_value(bytes[index * 2]) + .ok_or_else(|| TrackerParseError::InvalidHex(input.to_owned()))?; + let lo = hex_value(bytes[index * 2 + 1]) + .ok_or_else(|| TrackerParseError::InvalidHex(input.to_owned()))?; + *slot = (hi << 4) | lo; + } + Ok(out) +} + +/// Copies a raw 20-byte tracker field into a fixed-size array. +pub(super) fn bytes_to_20(bytes: &[u8]) -> Result<[u8; 20], TrackerParseError> { + if bytes.len() != 20 { + return Err(TrackerParseError::InvalidPeer( + "peer id must be 20 bytes".to_owned(), + )); + } + let mut out = [0_u8; 20]; + out.copy_from_slice(bytes); + Ok(out) +} + +/// Saturates an integer-like scrape field into `u32`. +pub(super) fn i64_to_u32(value: i64) -> u32 { + u32::try_from(value.max(0)).unwrap_or(u32::MAX) +} + +/// Saturates an integer-like port field into `u16`. +pub(super) fn i64_to_u16(value: i64) -> u16 { + u16::try_from(value.max(0)).unwrap_or(u16::MAX) +} + +/// Parses one bencode value and returns the decoded value plus the next byte index. +fn parse_value(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> { + match input.get(index).copied() { + Some(b'i') => parse_int(input, index), + Some(b'l') => parse_list(input, index), + Some(b'd') => parse_dict(input, index), + Some(b'0'..=b'9') => { + let (bytes, next) = parse_bytes(input, index)?; + Ok((BencodeValue::Bytes(bytes), next)) + } + _ => Err(TrackerParseError::InvalidBencode( + "invalid bencode value".to_owned(), + )), + } +} + +/// Parses one bencode integer starting at `index`. +fn parse_int(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> { + let mut cursor = index + 1; + let start = cursor; + while cursor < input.len() && input[cursor] != b'e' { + cursor += 1; + } + if cursor >= input.len() { + return Err(TrackerParseError::InvalidBencode( + "unterminated integer".to_owned(), + )); + } + let number = std::str::from_utf8(&input[start..cursor]) + .map_err(|_| TrackerParseError::InvalidBencode("invalid integer utf-8".to_owned()))? + .parse::() + .map_err(|_| TrackerParseError::InvalidBencode("invalid integer value".to_owned()))?; + Ok((BencodeValue::Int(number), cursor + 1)) +} + +/// Parses one bencode list starting at `index`. +fn parse_list(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> { + let mut cursor = index + 1; + let mut values = Vec::new(); + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((BencodeValue::List(values), cursor + 1)); + } + let (value, next) = parse_value(input, cursor)?; + values.push(value); + cursor = next; + } + Err(TrackerParseError::InvalidBencode( + "unterminated list".to_owned(), + )) +} + +/// Parses one bencode dictionary starting at `index`. +fn parse_dict(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> { + let mut cursor = index + 1; + let mut map = BTreeMap::new(); + while cursor < input.len() { + if input[cursor] == b'e' { + return Ok((BencodeValue::Dict(map), cursor + 1)); + } + let (key_bytes, next) = parse_bytes(input, cursor)?; + cursor = next; + let (value, next) = parse_value(input, cursor)?; + cursor = next; + map.insert(key_bytes, value); + } + Err(TrackerParseError::InvalidBencode( + "unterminated dictionary".to_owned(), + )) +} + +/// Parses one bencode byte string starting at `index`. +fn parse_bytes(input: &[u8], index: usize) -> Result<(Vec, usize), TrackerParseError> { + let mut cursor = index; + while cursor < input.len() && input[cursor].is_ascii_digit() { + cursor += 1; + } + if cursor == index || cursor >= input.len() || input[cursor] != b':' { + return Err(TrackerParseError::InvalidBencode( + "invalid byte string".to_owned(), + )); + } + let len = std::str::from_utf8(&input[index..cursor]) + .map_err(|_| TrackerParseError::InvalidBencode("invalid byte string length".to_owned()))? + .parse::() + .map_err(|_| TrackerParseError::InvalidBencode("invalid byte string length".to_owned()))?; + let start = cursor + 1; + let end = start.saturating_add(len); + if end > input.len() { + return Err(TrackerParseError::InvalidBencode( + "truncated byte string".to_owned(), + )); + } + Ok((input[start..end].to_vec(), end)) +} + +/// Converts one tracker peer dictionary entry into the higher-level peer model. +fn parse_peer_dict(value: &BencodeValue) -> Result { + let BencodeValue::Dict(dict) = value else { + return Err(TrackerParseError::InvalidPeer( + "peer entry must be a dictionary".to_owned(), + )); + }; + + let ip = dict_get_bytes(dict, "ip") + .map(bytes_to_string) + .ok_or(TrackerParseError::MissingField("peer.ip"))?; + let port = + i64_to_u16(dict_get_int(dict, "port").ok_or(TrackerParseError::MissingField("peer.port"))?); + let peer_id = dict_get_bytes(dict, "peer id").and_then(|bytes| bytes_to_20(bytes).ok()); + let client_name = dict_get_bytes(dict, "client").map(bytes_to_string); + let choked = dict_get_bool(dict, "choked").unwrap_or(false); + let interested = dict_get_bool(dict, "interested").unwrap_or(false); + + Ok(TorrentPeerModel { + peer_id, + ip, + port, + client_name, + interested, + choked, + }) +} + +/// Parses a compact peer blob, auto-detecting IPv4 vs IPv6 stride. +fn parse_compact_peers(bytes: &[u8]) -> Result, TrackerParseError> { + if bytes.len().is_multiple_of(6) { + parse_compact_peers_ipv4(bytes) + } else if bytes.len().is_multiple_of(18) { + parse_compact_peers_ipv6(bytes) + } else { + Err(TrackerParseError::InvalidPeer( + "compact peer list length must be divisible by 6 or 18".to_owned(), + )) + } +} + +/// Parses a compact IPv6 peer blob into tracker peer models. +fn parse_compact_peers_ipv6(bytes: &[u8]) -> Result, TrackerParseError> { + parse_compact_peers_with_stride(bytes, 18, |chunk| { + let ip = format!( + "{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}", + u16::from_be_bytes([chunk[0], chunk[1]]), + u16::from_be_bytes([chunk[2], chunk[3]]), + u16::from_be_bytes([chunk[4], chunk[5]]), + u16::from_be_bytes([chunk[6], chunk[7]]), + u16::from_be_bytes([chunk[8], chunk[9]]), + u16::from_be_bytes([chunk[10], chunk[11]]), + u16::from_be_bytes([chunk[12], chunk[13]]), + u16::from_be_bytes([chunk[14], chunk[15]]) + ); + let port = u16::from_be_bytes([chunk[16], chunk[17]]); + TorrentPeerModel { + peer_id: None, + ip, + port, + client_name: None, + interested: false, + choked: false, + } + }) +} + +/// Parses a compact peer blob using the provided stride and address decoder. +fn parse_compact_peers_with_stride( + bytes: &[u8], + stride: usize, + map_peer: F, +) -> Result, TrackerParseError> +where + F: FnMut(&[u8]) -> TorrentPeerModel, +{ + if bytes.is_empty() { + return Ok(Vec::new()); + } + if !bytes.len().is_multiple_of(stride) { + return Err(TrackerParseError::InvalidPeer(format!( + "compact peer list length must be divisible by {stride}" + ))); + } + Ok(bytes.chunks_exact(stride).map(map_peer).collect()) +} + +/// Looks up a boolean-like integer field inside a tracker bencode dictionary. +fn dict_get_bool(dict: &BencodeDict, key: &str) -> Option { + dict_get_int(dict, key).map(|value| value != 0) +} + +/// Percent-encodes one tracker query value, special-casing 20-byte binary fields. +fn percent_encode_tracker_value(key: &str, value: &str) -> Result { + if key == "info_hash" || key == "peer_id" { + let bytes = decode_hex_20(value)?; + return Ok(percent_encode_bytes(&bytes)); + } + Ok(percent_encode(value)) +} + +/// Percent-encodes one string-valued tracker query component. +fn percent_encode(input: &str) -> String { + percent_encode_bytes(input.as_bytes()) +} + +/// Percent-encodes arbitrary tracker query bytes using uppercase hexadecimal. +fn percent_encode_bytes(input: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut output = String::with_capacity(input.len()); + for &byte in input { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + output.push(char::from(byte)); + } + _ => { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + } + output +} + +/// Decodes one ASCII hex digit into its numeric nibble. +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} diff --git a/crates/aria2-rust-pro-protocol/src/tracker/request_response.rs b/crates/aria2-rust-pro-protocol/src/tracker/request_response.rs new file mode 100644 index 0000000..6dd3f44 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/request_response.rs @@ -0,0 +1,303 @@ +use super::{DhtMessageModel, TorrentPeerModel, TransportError}; +use crate::tracker::{ + TrackerParseError, UdpTrackerAnnounceEvent, UdpTrackerAnnounceRequest, UdpTrackerTransactionId, +}; + +/// Announce request fields sent to an HTTP or UDP tracker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackerRequestModel { + /// Base announce URL. + pub announce_url: String, + /// Hex-encoded torrent info hash. + pub info_hash: String, + /// Hex-encoded local peer identifier. + pub peer_id: String, + /// Listening port exposed to peers. + pub port: u16, + /// Uploaded byte counter sent to the tracker. + pub uploaded: u64, + /// Downloaded byte counter sent to the tracker. + pub downloaded: u64, + /// Remaining byte counter sent to the tracker. + pub left: u64, + /// Optional tracker lifecycle event. + pub event: Option, + /// Whether the tracker should prefer the compact peer format. + pub compact: bool, + /// Optional requested peer count. + pub numwant: Option, +} + +/// Peer list returned by a tracker announce response. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackerPeerListModel { + /// Recommended announce interval in seconds. + pub interval_sec: u32, + /// Parsed peer entries. + pub peers: Vec, + /// Optional minimum announce interval in seconds. + pub min_interval_sec: Option, + /// Optional tracker session identifier. + pub tracker_id: Option, +} + +/// DHT node coordinate returned by tracker or DHT metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DhtNodeModel { + /// Hex-encoded node identifier when available. + pub node_id: String, + /// Node IP address or hostname. + pub address: String, + /// Node UDP port. + pub port: u16, +} + +impl DhtNodeModel { + /// Parses a DHT bootstrap node from a `host:port` or `[ipv6]:port` spec. + /// + /// # Errors + /// + /// Returns an error when the node spec is malformed or the port is invalid. + pub fn from_spec(raw: &str) -> Result { + let (address, port) = super::parsing::parse_endpoint_spec(raw, "dht node")?; + Ok(Self { + node_id: String::new(), + address, + port, + }) + } + + /// Formats the node as a stable `host:port` or `[ipv6]:port` spec. + #[must_use] + pub fn to_spec(&self) -> String { + super::parsing::format_endpoint_spec(&self.address, self.port) + } +} + +/// Scrape statistics for a single info hash. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackerScrapeFileModel { + /// Hex-encoded info hash the entry describes. + pub info_hash: String, + /// Number of completed downloads. + pub complete: Option, + /// Number of times the torrent was downloaded. + pub downloaded: Option, + /// Number of incomplete peers. + pub incomplete: Option, +} + +/// Scrape summary returned by a tracker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackerScrapeModel { + /// Aggregate completed download count when present. + pub complete: Option, + /// Aggregate download count when present. + pub downloaded: Option, + /// Aggregate incomplete peer count when present. + pub incomplete: Option, + /// Per-info-hash scrape entries. + pub files: Vec, +} + +/// Parsed tracker response containing announce peers and optional scrape data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackerResponseModel { + /// Peer-list payload from the announce response. + pub peers: TrackerPeerListModel, + /// Optional scrape metadata synthesized from the response. + pub scrape: Option, +} + +impl TrackerRequestModel { + /// Builds a tracker request from raw `BitTorrent` info-hash and peer-id bytes. + #[must_use] + pub fn from_bt_bytes( + announce_url: impl Into, + info_hash: [u8; 20], + peer_id: [u8; 20], + port: u16, + uploaded: u64, + downloaded: u64, + left: u64, + ) -> Self { + Self { + announce_url: announce_url.into(), + info_hash: super::parsing::hex_encode(&info_hash), + peer_id: super::parsing::hex_encode(&peer_id), + port, + uploaded, + downloaded, + left, + event: None, + compact: true, + numwant: Some(50), + } + } + + /// Returns sorted query pairs suitable for announce and scrape URLs. + #[must_use] + pub fn query_pairs(&self) -> Vec<(String, String)> { + let mut pairs = vec![ + ("info_hash".to_owned(), self.info_hash.clone()), + ("peer_id".to_owned(), self.peer_id.clone()), + ("port".to_owned(), self.port.to_string()), + ("uploaded".to_owned(), self.uploaded.to_string()), + ("downloaded".to_owned(), self.downloaded.to_string()), + ("left".to_owned(), self.left.to_string()), + ("compact".to_owned(), u8::from(self.compact).to_string()), + ]; + if let Some(event) = &self.event { + pairs.push(("event".to_owned(), event.clone())); + } + if let Some(numwant) = self.numwant { + pairs.push(("numwant".to_owned(), numwant.to_string())); + } + pairs + } + + /// Builds the full announce URL with query parameters. + /// + /// # Errors + /// + /// Returns an error when the announce URL is invalid. + pub fn announce_url(&self) -> Result { + super::parsing::build_url(&self.announce_url, &self.query_pairs()) + } + + /// Builds the matching scrape URL with query parameters. + /// + /// # Errors + /// + /// Returns an error when the derived scrape URL is invalid. + pub fn scrape_url(&self) -> Result { + let base = super::parsing::tracker_scrape_url(&self.announce_url); + super::parsing::build_url(&base, &self.query_pairs()) + } + + /// Decodes the hex-encoded info hash into its raw 20-byte form. + /// + /// # Errors + /// + /// Returns an error when the info hash is not a valid 20-byte hex string. + pub fn info_hash_bytes(&self) -> Result<[u8; 20], TrackerParseError> { + super::parsing::decode_hex_20(&self.info_hash) + } + + /// Decodes the hex-encoded peer id into its raw 20-byte form. + /// + /// # Errors + /// + /// Returns an error when the peer id is not a valid 20-byte hex string. + pub fn peer_id_bytes(&self) -> Result<[u8; 20], TrackerParseError> { + super::parsing::decode_hex_20(&self.peer_id) + } + + /// Converts the higher-level request into a UDP tracker announce request. + /// + /// # Errors + /// + /// Returns an error when the info hash, peer id, event, or numwant cannot be represented + /// in a UDP announce packet. + pub fn to_udp_announce_request( + &self, + connection_id: u64, + transaction_id: UdpTrackerTransactionId, + ) -> Result { + let event = match self.event.as_deref() { + None | Some("") => UdpTrackerAnnounceEvent::None, + Some("completed") => UdpTrackerAnnounceEvent::Completed, + Some("started") => UdpTrackerAnnounceEvent::Started, + Some("stopped") => UdpTrackerAnnounceEvent::Stopped, + Some(other) => { + return Err(TrackerParseError::InvalidUdpPacket(format!( + "unsupported udp tracker event: {other}" + ))); + } + }; + let numwant = match self.numwant { + Some(numwant) => i32::try_from(numwant).map_err(|_| { + TrackerParseError::InvalidUdpPacket(format!( + "udp tracker numwant exceeds i32 range: {numwant}" + )) + })?, + None => -1, + }; + + Ok(UdpTrackerAnnounceRequest { + connection_id, + transaction_id, + info_hash: self.info_hash_bytes()?, + peer_id: self.peer_id_bytes()?, + downloaded: self.downloaded, + left: self.left, + uploaded: self.uploaded, + event, + ip_address: 0, + key: 0, + numwant, + port: self.port, + }) + } +} + +impl TrackerResponseModel { + /// Parses an HTTP tracker announce payload. + /// + /// # Errors + /// + /// Returns an error when the payload is malformed bencode or lacks required fields. + pub fn from_announce_bytes(input: &[u8]) -> Result { + let root = super::parsing::parse_bencode(input)?; + let interval_sec = super::parsing::i64_to_u32( + super::parsing::dict_get_int(&root, "interval").unwrap_or(1800), + ); + let min_interval_sec = + super::parsing::dict_get_int(&root, "min interval").map(super::parsing::i64_to_u32); + let tracker_id = super::parsing::dict_get_bytes(&root, "tracker id") + .map(super::parsing::bytes_to_string); + let peers = super::parsing::parse_peer_list(&root)?; + let scrape = super::parsing::parse_scrape_section(&root); + Ok(Self { + peers: TrackerPeerListModel { + interval_sec, + peers, + min_interval_sec, + tracker_id, + }, + scrape, + }) + } + + /// Parses an HTTP tracker scrape payload. + /// + /// # Errors + /// + /// Returns an error when the payload is malformed or lacks scrape metadata. + pub fn from_scrape_bytes(input: &[u8]) -> Result { + let root = super::parsing::parse_bencode(input)?; + super::parsing::parse_scrape_section_from_root(&root) + .ok_or(TrackerParseError::MissingField("files")) + } +} + +/// Tracker transport contract for announce and scrape requests. +pub trait TrackerTransport { + /// Executes a tracker announce request. + fn announce( + &self, + request: &TrackerRequestModel, + ) -> Result; + /// Executes a tracker scrape request for the given announce URL. + fn scrape(&self, announce_url: &str) -> Result; +} + +/// DHT transport contract for request/response messaging. +pub trait DhtTransport { + /// Sends a DHT message to the target node and returns the response. + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result; +} diff --git a/crates/aria2-rust-pro-protocol/src/tracker/reqwest_transport.rs b/crates/aria2-rust-pro-protocol/src/tracker/reqwest_transport.rs new file mode 100644 index 0000000..1792f27 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/reqwest_transport.rs @@ -0,0 +1,107 @@ +use super::{Client, TransportError, TransportErrorKind}; +use crate::tracker::{ + TrackerRequestModel, TrackerResponseModel, TrackerScrapeModel, TrackerTransport, +}; + +/// `reqwest`-backed HTTP tracker transport. +#[derive(Clone, Debug)] +pub struct ReqwestTrackerTransport { + /// Shared blocking HTTP client used for announce and scrape requests. + client: Client, +} + +impl ReqwestTrackerTransport { + /// Creates a tracker transport backed by a default blocking `reqwest` client. + /// + /// # Errors + /// + /// Returns an error when the HTTP client cannot be constructed. + pub fn new() -> Result { + let client = Client::builder() + .build() + .map_err(|error| tracker_transport_error(TransportErrorKind::Io, error.to_string()))?; + Ok(Self { client }) + } + + /// Fetches the raw response bytes for one tracker announce or scrape URL. + fn fetch_bytes(&self, url: &str) -> Result, TransportError> { + let response = self + .client + .get(url) + .send() + .map_err(map_reqwest_tracker_error)?; + let status = response.status(); + if !status.is_success() { + return Err(tracker_transport_error( + TransportErrorKind::ProtocolViolation, + format!( + "tracker request failed with http status {}", + status.as_u16() + ), + )); + } + response + .bytes() + .map(Vec::from) + .map_err(map_reqwest_tracker_error) + } +} + +impl Default for ReqwestTrackerTransport { + fn default() -> Self { + Self::new().expect("reqwest tracker transport should build") + } +} + +impl TrackerTransport for ReqwestTrackerTransport { + fn announce( + &self, + request: &TrackerRequestModel, + ) -> Result { + let url = request.announce_url().map_err(|error| { + tracker_transport_error(TransportErrorKind::ProtocolViolation, error.to_string()) + })?; + let bytes = self.fetch_bytes(&url)?; + TrackerResponseModel::from_announce_bytes(&bytes).map_err(|error| { + tracker_transport_error( + TransportErrorKind::ProtocolViolation, + format!("invalid tracker announce payload: {error}"), + ) + }) + } + + fn scrape(&self, announce_url: &str) -> Result { + let url = super::parsing::tracker_scrape_url(announce_url); + let bytes = self.fetch_bytes(&url)?; + TrackerResponseModel::from_scrape_bytes(&bytes).map_err(|error| { + tracker_transport_error( + TransportErrorKind::ProtocolViolation, + format!("invalid tracker scrape payload: {error}"), + ) + }) + } +} + +/// Builds a transport error with tracker-specific context already normalized. +fn tracker_transport_error(kind: TransportErrorKind, message: impl Into) -> TransportError { + TransportError { + kind, + message: message.into(), + source: None, + context: None, + } +} + +/// Maps a reqwest tracker fetch failure into the transport error model. +fn map_reqwest_tracker_error(error: reqwest::Error) -> TransportError { + let kind = if error.is_timeout() { + TransportErrorKind::Timeout + } else if error.is_connect() { + TransportErrorKind::NotConnected + } else if error.is_decode() { + TransportErrorKind::ProtocolViolation + } else { + TransportErrorKind::Io + }; + tracker_transport_error(kind, error.to_string()) +} diff --git a/crates/aria2-rust-pro-protocol/src/tracker/tracker_tests.rs b/crates/aria2-rust-pro-protocol/src/tracker/tracker_tests.rs new file mode 100644 index 0000000..9ff6988 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/tracker_tests.rs @@ -0,0 +1,567 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; + +use super::*; + +#[test] +fn builds_announce_and_scrape_urls() { + let request = TrackerRequestModel { + announce_url: "https://tracker.example.org/announce".to_owned(), + info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), + peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(), + port: 6881, + uploaded: 1, + downloaded: 2, + left: 3, + event: Some("started".to_owned()), + compact: true, + numwant: Some(50), + }; + + let announce = request.announce_url().expect("announce url should build"); + let scrape = request.scrape_url().expect("scrape url should build"); + + assert!(announce.starts_with("https://tracker.example.org/announce?")); + assert!(announce.contains("info_hash=%")); + assert!(announce.contains("peer_id=%")); + assert!(announce.contains("event=started")); + assert!(scrape.starts_with("https://tracker.example.org/scrape?")); + assert!(scrape.contains("info_hash=%")); +} + +#[test] +fn parses_dict_announce_response_with_peer_metadata() { + let response = announce_bytes( + 1800, + Some("tracker-01"), + vec![peer_dict( + "127.0.0.1", + 6881, + Some("qBittorrent 4.6.5"), + true, + false, + Some([1_u8; 20]), + )], + None, + ); + + let parsed = + TrackerResponseModel::from_announce_bytes(&response).expect("should parse announce"); + + assert_eq!(parsed.peers.interval_sec, 1800); + assert_eq!(parsed.peers.tracker_id.as_deref(), Some("tracker-01")); + assert_eq!(parsed.peers.peers.len(), 1); + assert_eq!(parsed.peers.peers[0].ip, "127.0.0.1"); + assert_eq!(parsed.peers.peers[0].port, 6881); + assert_eq!( + parsed.peers.peers[0].client_name.as_deref(), + Some("qBittorrent 4.6.5") + ); + assert!(parsed.peers.peers[0].choked); + assert!(!parsed.peers.peers[0].interested); + assert_eq!(parsed.peers.peers[0].peer_id, Some([1_u8; 20])); +} + +#[test] +fn parses_tracker_id_and_scrape_metadata_from_announce_response() { + let response = announce_bytes( + 900, + Some("tracker-02"), + vec![peer_dict("127.0.0.2", 6882, None, false, true, None)], + Some((7, 3, 11)), + ); + + let parsed = + TrackerResponseModel::from_announce_bytes(&response).expect("should parse announce"); + + assert_eq!(parsed.peers.tracker_id.as_deref(), Some("tracker-02")); + assert_eq!(parsed.peers.interval_sec, 900); + let scrape = parsed.scrape.expect("scrape metadata should be present"); + assert_eq!(scrape.complete, Some(7)); + assert_eq!(scrape.incomplete, Some(3)); + assert_eq!(scrape.downloaded, Some(11)); + assert!(scrape.files.is_empty()); +} + +#[test] +fn parses_scrape_response_with_binary_info_hash_keys() { + let info_hash_a = [0x11_u8; 20]; + let info_hash_b = [0x22_u8; 20]; + let response = bencode_dict(vec![( + "files".to_owned(), + bencode_binary_key_dict(vec![ + ( + info_hash_a.to_vec(), + bencode_dict(vec![ + ("complete".to_owned(), bencode_int(7)), + ("downloaded".to_owned(), bencode_int(9)), + ("incomplete".to_owned(), bencode_int(3)), + ]), + ), + ( + info_hash_b.to_vec(), + bencode_dict(vec![ + ("complete".to_owned(), bencode_int(4)), + ("downloaded".to_owned(), bencode_int(5)), + ("incomplete".to_owned(), bencode_int(6)), + ]), + ), + ]), + )]); + + let parsed = TrackerResponseModel::from_scrape_bytes(&response).expect("should parse scrape"); + + assert_eq!(parsed.files.len(), 2); + assert_eq!(parsed.files[0].info_hash, hex_encode(&info_hash_a)); + assert_eq!(parsed.files[0].complete, Some(7)); + assert_eq!(parsed.files[0].downloaded, Some(9)); + assert_eq!(parsed.files[0].incomplete, Some(3)); + assert_eq!(parsed.files[1].info_hash, hex_encode(&info_hash_b)); + assert_eq!(parsed.files[1].complete, Some(4)); + assert_eq!(parsed.files[1].downloaded, Some(5)); + assert_eq!(parsed.files[1].incomplete, Some(6)); +} + +#[test] +fn udp_connect_request_serializes_expected_wire_format() { + let request = UdpTrackerConnectRequest { + transaction_id: UdpTrackerTransactionId::new(0x1020_3040), + }; + + assert_eq!( + request.encode(), + vec![ + 0x00, 0x00, 0x04, 0x17, 0x27, 0x10, 0x19, 0x80, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, + 0x30, 0x40, + ] + ); +} + +#[test] +fn udp_connect_response_parses_header_and_connection_id() { + let response = [ + 0x00, 0x00, 0x00, 0x00, 0xaa, 0xbb, 0xcc, 0xdd, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, + ]; + + let parsed = + UdpTrackerConnectResponse::decode(&response).expect("connect response should parse"); + + assert_eq!( + parsed.transaction_id, + UdpTrackerTransactionId::new(0xaabb_ccdd) + ); + assert_eq!(parsed.connection_id, 0x0102_0304_0506_0708); +} + +#[test] +fn udp_response_header_rejects_invalid_action_id() { + let error = UdpTrackerResponseHeader::decode(&[0x00, 0x00, 0x00, 0x09, 0xaa, 0xbb, 0xcc, 0xdd]) + .expect_err("invalid action id should fail"); + + assert!(matches!(error, TrackerParseError::InvalidUdpAction(9))); +} + +#[test] +fn udp_response_header_rejects_truncated_payload() { + let error = UdpTrackerResponseHeader::decode(&[0x00, 0x00, 0x00]) + .expect_err("truncated header should fail"); + + assert!(matches!(error, TrackerParseError::InvalidUdpPacket(_))); +} + +#[test] +fn udp_response_header_rejects_transaction_id_mismatch() { + let header = UdpTrackerResponseHeader { + action: UdpTrackerAction::Announce, + transaction_id: UdpTrackerTransactionId::new(7), + }; + + let error = header + .expect_transaction_id(UdpTrackerTransactionId::new(8)) + .expect_err("mismatched transaction id should fail"); + + assert!(matches!( + error, + TrackerParseError::TransactionIdMismatch { + expected: 8, + actual: 7 + } + )); +} + +#[test] +fn udp_announce_request_serializes_expected_wire_format() { + let request = UdpTrackerAnnounceRequest { + connection_id: 0x0102_0304_0506_0708, + transaction_id: UdpTrackerTransactionId::new(0x5566_7788), + info_hash: [0x11_u8; 20], + peer_id: [0x22_u8; 20], + downloaded: 0x1122_3344_5566_7788, + left: 0x8877_6655_4433_2211, + uploaded: 0x0101_0202_0303_0404, + event: UdpTrackerAnnounceEvent::Started, + ip_address: 0, + key: 0x1234_5678, + numwant: -1, + port: 6881, + }; + + let encoded = request.encode(); + + assert_eq!(encoded.len(), 98); + assert_eq!( + &encoded[0..8], + &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + ); + assert_eq!(&encoded[8..12], &[0x00, 0x00, 0x00, 0x01]); + assert_eq!(&encoded[12..16], &[0x55, 0x66, 0x77, 0x88]); + assert_eq!(&encoded[16..36], &[0x11_u8; 20]); + assert_eq!(&encoded[36..56], &[0x22_u8; 20]); + assert_eq!(&encoded[80..84], &[0x00, 0x00, 0x00, 0x02]); + assert_eq!(&encoded[88..92], &[0x12, 0x34, 0x56, 0x78]); + assert_eq!(&encoded[92..96], &[0xff, 0xff, 0xff, 0xff]); + assert_eq!(&encoded[96..98], &[0x1a, 0xe1]); +} + +#[test] +fn udp_announce_response_parses_interval_counts_and_peers() { + let response = udp_announce_response_bytes( + UdpTrackerTransactionId::new(0x0102_0304), + 1800, + 4, + 9, + &[(192, 168, 1, 10, 6881), (10, 0, 0, 2, 51413)], + ); + + let parsed = + UdpTrackerAnnounceResponse::decode(&response).expect("announce response should parse"); + + assert_eq!( + parsed.transaction_id, + UdpTrackerTransactionId::new(0x0102_0304) + ); + assert_eq!(parsed.interval_sec, 1800); + assert_eq!(parsed.leechers, 4); + assert_eq!(parsed.seeders, 9); + assert_eq!(parsed.peers.len(), 2); + assert_eq!(parsed.peers[0].ip, "192.168.1.10"); + assert_eq!(parsed.peers[0].port, 6881); + assert_eq!(parsed.peers[1].ip, "10.0.0.2"); + assert_eq!(parsed.peers[1].port, 51413); +} + +#[test] +fn udp_announce_response_rejects_malformed_compact_peer_blob() { + let mut response = vec![ + 0x00, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef, 0x00, 0x00, 0x07, 0x08, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x06, + ]; + response.extend_from_slice(&[127, 0, 0, 1, 0x1a]); + + let error = + UdpTrackerAnnounceResponse::decode(&response).expect_err("malformed peers should fail"); + + assert!(matches!(error, TrackerParseError::InvalidPeer(_))); +} + +#[test] +fn udp_scrape_request_serializes_multiple_info_hashes() { + let request = UdpTrackerScrapeRequest { + connection_id: 0x1112_1314_1516_1718, + transaction_id: UdpTrackerTransactionId::new(0x99aa_bbcc), + info_hashes: vec![[0x44_u8; 20], [0x55_u8; 20]], + }; + + let encoded = request.encode().expect("scrape request should encode"); + + assert_eq!(encoded.len(), 56); + assert_eq!( + &encoded[0..8], + &[0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18] + ); + assert_eq!(&encoded[8..12], &[0x00, 0x00, 0x00, 0x02]); + assert_eq!(&encoded[12..16], &[0x99, 0xaa, 0xbb, 0xcc]); + assert_eq!(&encoded[16..36], &[0x44_u8; 20]); + assert_eq!(&encoded[36..56], &[0x55_u8; 20]); +} + +#[test] +fn udp_scrape_response_maps_multiple_entries_to_scrape_model() { + let response = udp_scrape_response_bytes( + UdpTrackerTransactionId::new(0x0bad_f00d), + &[(7, 9, 3), (4, 5, 6)], + ); + let parsed = UdpTrackerScrapeResponse::decode(&response).expect("scrape response should parse"); + let scrape = parsed + .to_scrape_model(&[[0x33_u8; 20], [0x44_u8; 20]]) + .expect("scrape model should build"); + + assert_eq!( + parsed.transaction_id, + UdpTrackerTransactionId::new(0x0bad_f00d) + ); + assert_eq!(scrape.files.len(), 2); + assert_eq!(scrape.files[0].info_hash, hex_encode(&[0x33_u8; 20])); + assert_eq!(scrape.files[0].complete, Some(7)); + assert_eq!(scrape.files[0].downloaded, Some(9)); + assert_eq!(scrape.files[0].incomplete, Some(3)); + assert_eq!(scrape.files[1].info_hash, hex_encode(&[0x44_u8; 20])); + assert_eq!(scrape.files[1].complete, Some(4)); + assert_eq!(scrape.files[1].downloaded, Some(5)); + assert_eq!(scrape.files[1].incomplete, Some(6)); +} + +#[test] +fn udp_scrape_response_rejects_truncated_payload() { + let error = UdpTrackerScrapeResponse::decode(&[ + 0x00, 0x00, 0x00, 0x02, 0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00, + ]) + .expect_err("truncated scrape payload should fail"); + + assert!(matches!(error, TrackerParseError::InvalidUdpPacket(_))); +} + +#[test] +fn reqwest_tracker_transport_executes_live_announce_request() { + 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 || { + let (mut stream, _) = listener.accept().expect("client should connect"); + let mut request = [0_u8; 2048]; + let read = stream.read(&mut request).expect("request should read"); + let request_text = String::from_utf8_lossy(&request[..read]); + assert!(request_text.starts_with("GET /announce?")); + assert!(request_text.contains("info_hash=")); + assert!(request_text.contains("peer_id=")); + assert!(request_text.contains("compact=1")); + assert!(request_text.contains("event=started")); + + let payload = announce_bytes( + 1200, + Some("live-tracker"), + vec![peer_dict( + "127.0.0.1", + 6881, + Some("local-peer"), + false, + true, + None, + )], + Some((5, 2, 9)), + ); + 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"); + }); + + let transport = ReqwestTrackerTransport::new().expect("tracker transport should build"); + let response = transport + .announce(&TrackerRequestModel { + announce_url: format!("http://{addr}/announce"), + info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), + peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(), + port: 6881, + uploaded: 0, + downloaded: 0, + left: 1024, + event: Some("started".to_owned()), + compact: true, + numwant: Some(25), + }) + .expect("announce should succeed"); + + assert_eq!(response.peers.interval_sec, 1200); + assert_eq!(response.peers.tracker_id.as_deref(), Some("live-tracker")); + assert_eq!(response.peers.peers.len(), 1); + assert_eq!(response.peers.peers[0].ip, "127.0.0.1"); + let scrape = response.scrape.expect("scrape stats should be present"); + assert_eq!(scrape.complete, Some(5)); + assert_eq!(scrape.incomplete, Some(2)); + assert_eq!(scrape.downloaded, Some(9)); + + handle.join().expect("server thread should join"); +} + +#[test] +fn dht_node_model_parses_and_formats_ipv4_and_ipv6_specs() { + let ipv4 = DhtNodeModel::from_spec("198.51.100.9:51413").expect("ipv4 should parse"); + assert_eq!(ipv4.address, "198.51.100.9"); + assert_eq!(ipv4.port, 51413); + assert_eq!(ipv4.to_spec(), "198.51.100.9:51413"); + + let ipv6 = DhtNodeModel::from_spec("[2001:db8::9]:6881").expect("ipv6 should parse"); + assert_eq!(ipv6.address, "2001:db8::9"); + assert_eq!(ipv6.port, 6881); + assert_eq!(ipv6.to_spec(), "[2001:db8::9]:6881"); +} + +#[test] +fn tracker_request_converts_into_udp_announce_request() { + let request = TrackerRequestModel { + announce_url: "udp://tracker.example.org:6969".to_owned(), + info_hash: "00112233445566778899aabbccddeeff00112233".to_owned(), + peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(), + port: 51413, + uploaded: 11, + downloaded: 22, + left: 33, + event: Some("started".to_owned()), + compact: true, + numwant: Some(40), + }; + + let udp = request + .to_udp_announce_request(0x1122_3344_5566_7788, UdpTrackerTransactionId::new(77)) + .expect("tracker request should convert"); + + assert_eq!(udp.connection_id, 0x1122_3344_5566_7788); + assert_eq!(udp.transaction_id, UdpTrackerTransactionId::new(77)); + assert_eq!(udp.info_hash[0], 0x00); + assert_eq!(udp.info_hash[19], 0x33); + assert_eq!(udp.peer_id[0], 0x89); + assert_eq!(udp.peer_id[19], 0xef); + assert_eq!(udp.event, UdpTrackerAnnounceEvent::Started); + assert_eq!(udp.numwant, 40); + assert_eq!(udp.port, 51413); +} + +fn announce_bytes( + interval: i64, + tracker_id: Option<&str>, + peers: Vec>, + scrape: Option<(i64, i64, i64)>, +) -> Vec { + let mut fields = Vec::new(); + fields.push(("interval".to_owned(), bencode_int(interval))); + if let Some(tracker_id) = tracker_id { + fields.push(( + "tracker id".to_owned(), + bencode_bytes(tracker_id.as_bytes()), + )); + } + fields.push(("peers".to_owned(), bencode_list(peers))); + if let Some((complete, incomplete, downloaded)) = scrape { + fields.push(("complete".to_owned(), bencode_int(complete))); + fields.push(("incomplete".to_owned(), bencode_int(incomplete))); + fields.push(("downloaded".to_owned(), bencode_int(downloaded))); + } + bencode_dict(fields) +} + +fn peer_dict( + ip: &str, + port: i64, + client: Option<&str>, + choked: bool, + interested: bool, + peer_id: Option<[u8; 20]>, +) -> Vec { + let mut fields = vec![ + ("ip".to_owned(), bencode_bytes(ip.as_bytes())), + ("port".to_owned(), bencode_int(port)), + ( + "choked".to_owned(), + bencode_int(i64::from(u8::from(choked))), + ), + ( + "interested".to_owned(), + bencode_int(i64::from(u8::from(interested))), + ), + ]; + if let Some(client) = client { + fields.push(("client".to_owned(), bencode_bytes(client.as_bytes()))); + } + if let Some(peer_id) = peer_id { + fields.push(("peer id".to_owned(), bencode_bytes(&peer_id))); + } + bencode_dict(fields) +} + +fn bencode_dict(fields: Vec<(String, Vec)>) -> Vec { + let mut out = Vec::from(b"d".as_slice()); + for (key, value) in fields { + out.extend_from_slice(key.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(key.as_bytes()); + out.extend_from_slice(&value); + } + out.push(b'e'); + out +} + +fn bencode_binary_key_dict(fields: Vec<(Vec, Vec)>) -> Vec { + let mut out = Vec::from(b"d".as_slice()); + for (key, value) in fields { + out.extend_from_slice(key.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(&key); + out.extend_from_slice(&value); + } + out.push(b'e'); + out +} + +fn bencode_list(values: Vec>) -> Vec { + let mut out = Vec::from(b"l".as_slice()); + for value in values { + out.extend_from_slice(&value); + } + out.push(b'e'); + out +} + +fn bencode_int(value: i64) -> Vec { + format!("i{value}e").into_bytes() +} + +fn bencode_bytes(value: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(value.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(value); + out +} + +fn udp_announce_response_bytes( + transaction_id: UdpTrackerTransactionId, + interval_sec: u32, + leechers: u32, + seeders: u32, + peers: &[(u8, u8, u8, u8, u16)], +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&1_u32.to_be_bytes()); + out.extend_from_slice(&transaction_id.get().to_be_bytes()); + out.extend_from_slice(&interval_sec.to_be_bytes()); + out.extend_from_slice(&leechers.to_be_bytes()); + out.extend_from_slice(&seeders.to_be_bytes()); + for (a, b, c, d, port) in peers { + out.extend_from_slice(&[*a, *b, *c, *d]); + out.extend_from_slice(&port.to_be_bytes()); + } + out +} + +fn udp_scrape_response_bytes( + transaction_id: UdpTrackerTransactionId, + entries: &[(u32, u32, u32)], +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&2_u32.to_be_bytes()); + out.extend_from_slice(&transaction_id.get().to_be_bytes()); + for (complete, downloaded, incomplete) in entries { + out.extend_from_slice(&complete.to_be_bytes()); + out.extend_from_slice(&downloaded.to_be_bytes()); + out.extend_from_slice(&incomplete.to_be_bytes()); + } + out +} diff --git a/crates/aria2-rust-pro-protocol/src/tracker/udp.rs b/crates/aria2-rust-pro-protocol/src/tracker/udp.rs new file mode 100644 index 0000000..bfe1295 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/tracker/udp.rs @@ -0,0 +1,490 @@ +use super::{AtomicU32, Ordering, TorrentPeerModel}; +use crate::tracker::{ + TrackerParseError, TrackerPeerListModel, TrackerResponseModel, TrackerScrapeFileModel, + TrackerScrapeModel, +}; + +/// UDP tracker protocol identifier from BEP 15. +pub const UDP_TRACKER_PROTOCOL_ID: u64 = 0x0417_2710_1980; + +/// Process-local counter used to allocate monotonic UDP tracker transaction ids. +static UDP_TRACKER_TRANSACTION_COUNTER: AtomicU32 = AtomicU32::new(0x6d69_0000); + +/// Monotonic transaction identifier used for UDP tracker requests. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerTransactionId(u32); + +impl UdpTrackerTransactionId { + /// Creates a transaction identifier from a raw integer value. + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + /// Allocates the next process-local UDP tracker transaction identifier. + #[must_use] + pub fn next() -> Self { + Self(UDP_TRACKER_TRANSACTION_COUNTER.fetch_add(1, Ordering::Relaxed)) + } + + /// Returns the raw integer value sent on the wire. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// UDP tracker actions defined by BEP 15. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UdpTrackerAction { + /// Connection-id bootstrap request/response. + Connect, + /// Announce request/response. + Announce, + /// Scrape request/response. + Scrape, + /// Error response. + Error, +} + +impl UdpTrackerAction { + /// Returns the wire value associated with the action. + #[must_use] + pub const fn wire_value(self) -> u32 { + match self { + Self::Connect => 0, + Self::Announce => 1, + Self::Scrape => 2, + Self::Error => 3, + } + } + + /// Decodes a raw BEP 15 action code into the typed tracker action. + fn decode(value: u32) -> Result { + match value { + 0 => Ok(Self::Connect), + 1 => Ok(Self::Announce), + 2 => Ok(Self::Scrape), + 3 => Ok(Self::Error), + _ => Err(TrackerParseError::InvalidUdpAction(value)), + } + } + + #[must_use] + /// Returns a short diagnostic label for the tracker action. + const fn label(self) -> &'static str { + match self { + Self::Connect => "connect", + Self::Announce => "announce", + Self::Scrape => "scrape", + Self::Error => "error", + } + } +} + +/// Announce lifecycle values defined by the UDP tracker protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UdpTrackerAnnounceEvent { + /// No explicit lifecycle event. + None, + /// Download completed. + Completed, + /// Download started. + Started, + /// Download stopped. + Stopped, +} + +impl UdpTrackerAnnounceEvent { + /// Returns the wire value associated with the announce event. + #[must_use] + pub const fn wire_value(self) -> u32 { + match self { + Self::None => 0, + Self::Completed => 1, + Self::Started => 2, + Self::Stopped => 3, + } + } +} + +/// Header shared by all UDP tracker responses. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerResponseHeader { + /// Action encoded by the response. + pub action: UdpTrackerAction, + /// Transaction identifier associated with the request/response pair. + pub transaction_id: UdpTrackerTransactionId, +} + +impl UdpTrackerResponseHeader { + /// Decodes a UDP tracker response header from raw bytes. + /// + /// # Errors + /// + /// Returns an error when the payload is truncated or the action code is invalid. + pub fn decode(input: &[u8]) -> Result { + ensure_udp_payload_len(input, 8, "tracker response header")?; + Ok(Self { + action: UdpTrackerAction::decode(read_u32_be(input, 0, "tracker action id")?)?, + transaction_id: UdpTrackerTransactionId::new(read_u32_be( + input, + 4, + "tracker transaction id", + )?), + }) + } + + /// Verifies that the header action matches the expected value. + /// + /// # Errors + /// + /// Returns an error when the action differs from `expected`. + pub fn expect_action(self, expected: UdpTrackerAction) -> Result { + if self.action == expected { + Ok(self) + } else { + Err(TrackerParseError::InvalidUdpPacket(format!( + "expected tracker action {} but got {}", + expected.label(), + self.action.label() + ))) + } + } + + /// Verifies that the header transaction identifier matches the expected value. + /// + /// # Errors + /// + /// Returns an error when the transaction identifier differs from `expected`. + pub fn expect_transaction_id( + self, + expected: UdpTrackerTransactionId, + ) -> Result { + if self.transaction_id == expected { + Ok(self) + } else { + Err(TrackerParseError::TransactionIdMismatch { + expected: expected.get(), + actual: self.transaction_id.get(), + }) + } + } +} + +/// UDP tracker connect request payload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerConnectRequest { + /// Transaction identifier to match in the response. + pub transaction_id: UdpTrackerTransactionId, +} + +impl UdpTrackerConnectRequest { + /// Encodes the request into BEP 15 wire bytes. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(16); + out.extend_from_slice(&UDP_TRACKER_PROTOCOL_ID.to_be_bytes()); + out.extend_from_slice(&UdpTrackerAction::Connect.wire_value().to_be_bytes()); + out.extend_from_slice(&self.transaction_id.get().to_be_bytes()); + out + } +} + +/// UDP tracker connect response payload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerConnectResponse { + /// Transaction identifier echoed by the tracker. + pub transaction_id: UdpTrackerTransactionId, + /// Connection identifier used by later requests. + pub connection_id: u64, +} + +impl UdpTrackerConnectResponse { + /// Decodes a connect response from BEP 15 wire bytes. + /// + /// # Errors + /// + /// Returns an error when the payload is truncated or malformed. + pub fn decode(input: &[u8]) -> Result { + ensure_udp_payload_len(input, 16, "connect response")?; + let header = + UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Connect)?; + Ok(Self { + transaction_id: header.transaction_id, + connection_id: read_u64_be(input, 8, "tracker connection id")?, + }) + } +} + +/// UDP tracker announce request payload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerAnnounceRequest { + /// Connection identifier previously returned by the tracker. + pub connection_id: u64, + /// Transaction identifier to match in the response. + pub transaction_id: UdpTrackerTransactionId, + /// Raw 20-byte torrent info hash. + pub info_hash: [u8; 20], + /// Raw 20-byte local peer identifier. + pub peer_id: [u8; 20], + /// Uploaded byte count. + pub downloaded: u64, + /// Remaining byte count. + pub left: u64, + /// Uploaded byte count. + pub uploaded: u64, + /// Announce lifecycle event. + pub event: UdpTrackerAnnounceEvent, + /// Optional explicit IPv4 address encoded as a `u32`. + pub ip_address: u32, + /// Opaque tracker key. + pub key: u32, + /// Desired peer count or `-1` for tracker default. + pub numwant: i32, + /// Listening port exposed to peers. + pub port: u16, +} + +impl UdpTrackerAnnounceRequest { + /// Encodes the announce request into BEP 15 wire bytes. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(98); + out.extend_from_slice(&self.connection_id.to_be_bytes()); + out.extend_from_slice(&UdpTrackerAction::Announce.wire_value().to_be_bytes()); + out.extend_from_slice(&self.transaction_id.get().to_be_bytes()); + out.extend_from_slice(&self.info_hash); + out.extend_from_slice(&self.peer_id); + out.extend_from_slice(&self.downloaded.to_be_bytes()); + out.extend_from_slice(&self.left.to_be_bytes()); + out.extend_from_slice(&self.uploaded.to_be_bytes()); + out.extend_from_slice(&self.event.wire_value().to_be_bytes()); + out.extend_from_slice(&self.ip_address.to_be_bytes()); + out.extend_from_slice(&self.key.to_be_bytes()); + out.extend_from_slice(&self.numwant.to_be_bytes()); + out.extend_from_slice(&self.port.to_be_bytes()); + out + } +} + +/// UDP tracker announce response payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UdpTrackerAnnounceResponse { + /// Transaction identifier echoed by the tracker. + pub transaction_id: UdpTrackerTransactionId, + /// Recommended announce interval in seconds. + pub interval_sec: u32, + /// Number of incomplete peers. + pub leechers: u32, + /// Number of complete peers. + pub seeders: u32, + /// Parsed compact IPv4 peer list. + pub peers: Vec, +} + +impl UdpTrackerAnnounceResponse { + /// Decodes an announce response from BEP 15 wire bytes. + /// + /// # Errors + /// + /// Returns an error when the payload is truncated or malformed. + pub fn decode(input: &[u8]) -> Result { + ensure_udp_payload_len(input, 20, "announce response")?; + let header = + UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Announce)?; + Ok(Self { + transaction_id: header.transaction_id, + interval_sec: read_u32_be(input, 8, "announce interval")?, + leechers: read_u32_be(input, 12, "announce leechers")?, + seeders: read_u32_be(input, 16, "announce seeders")?, + peers: super::parsing::parse_compact_peers_ipv4(&input[20..])?, + }) + } + + /// Converts the announce response into the higher-level tracker response model. + #[must_use] + pub fn into_tracker_response(self) -> TrackerResponseModel { + TrackerResponseModel { + peers: TrackerPeerListModel { + interval_sec: self.interval_sec, + peers: self.peers, + min_interval_sec: None, + tracker_id: None, + }, + scrape: None, + } + } +} + +/// UDP tracker scrape request payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UdpTrackerScrapeRequest { + /// Connection identifier previously returned by the tracker. + pub connection_id: u64, + /// Transaction identifier to match in the response. + pub transaction_id: UdpTrackerTransactionId, + /// Raw 20-byte info hashes to scrape. + pub info_hashes: Vec<[u8; 20]>, +} + +impl UdpTrackerScrapeRequest { + /// Encodes the scrape request into BEP 15 wire bytes. + /// + /// # Errors + /// + /// Returns an error when no info hashes were supplied. + pub fn encode(&self) -> Result, TrackerParseError> { + if self.info_hashes.is_empty() { + return Err(TrackerParseError::InvalidUdpPacket( + "udp tracker scrape request requires at least one info hash".to_owned(), + )); + } + + let mut out = Vec::with_capacity(16 + self.info_hashes.len() * 20); + out.extend_from_slice(&self.connection_id.to_be_bytes()); + out.extend_from_slice(&UdpTrackerAction::Scrape.wire_value().to_be_bytes()); + out.extend_from_slice(&self.transaction_id.get().to_be_bytes()); + for info_hash in &self.info_hashes { + out.extend_from_slice(info_hash); + } + Ok(out) + } +} + +/// Per-torrent counters returned by a UDP tracker scrape response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UdpTrackerScrapeStats { + /// Number of completed downloads. + pub complete: u32, + /// Number of completed client downloads. + pub downloaded: u32, + /// Number of incomplete peers. + pub incomplete: u32, +} + +/// UDP tracker scrape response payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UdpTrackerScrapeResponse { + /// Transaction identifier echoed by the tracker. + pub transaction_id: UdpTrackerTransactionId, + /// Per-info-hash scrape statistics. + pub files: Vec, +} + +impl UdpTrackerScrapeResponse { + /// Decodes a scrape response from BEP 15 wire bytes. + /// + /// # Errors + /// + /// Returns an error when the payload is truncated or malformed. + pub fn decode(input: &[u8]) -> Result { + ensure_udp_payload_len(input, 8, "scrape response header")?; + let header = + UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Scrape)?; + let payload = &input[8..]; + if !payload.len().is_multiple_of(12) { + return Err(TrackerParseError::InvalidUdpPacket( + "scrape response payload length must be divisible by 12".to_owned(), + )); + } + + let mut files = Vec::with_capacity(payload.len() / 12); + for offset in (0..payload.len()).step_by(12) { + files.push(UdpTrackerScrapeStats { + complete: read_u32_be(payload, offset, "scrape complete count")?, + downloaded: read_u32_be(payload, offset + 4, "scrape downloaded count")?, + incomplete: read_u32_be(payload, offset + 8, "scrape incomplete count")?, + }); + } + + Ok(Self { + transaction_id: header.transaction_id, + files, + }) + } + + /// Converts the response into the higher-level scrape model. + /// + /// # Errors + /// + /// Returns an error when the info-hash list does not match the response entry count. + pub fn to_scrape_model( + &self, + info_hashes: &[[u8; 20]], + ) -> Result { + if info_hashes.len() != self.files.len() { + return Err(TrackerParseError::InvalidUdpPacket(format!( + "scrape response entry count {} does not match info-hash count {}", + self.files.len(), + info_hashes.len() + ))); + } + + let files = info_hashes + .iter() + .zip(self.files.iter()) + .map(|(info_hash, stats)| TrackerScrapeFileModel { + info_hash: super::parsing::hex_encode(info_hash), + complete: Some(stats.complete), + downloaded: Some(stats.downloaded), + incomplete: Some(stats.incomplete), + }) + .collect::>(); + + let (complete, downloaded, incomplete) = if self.files.len() == 1 { + let stats = self.files[0]; + ( + Some(stats.complete), + Some(stats.downloaded), + Some(stats.incomplete), + ) + } else { + (None, None, None) + }; + + Ok(TrackerScrapeModel { + complete, + downloaded, + incomplete, + files, + }) + } +} + +/// Verifies that a UDP tracker payload is at least `min_len` bytes long. +fn ensure_udp_payload_len( + input: &[u8], + min_len: usize, + label: &str, +) -> Result<(), TrackerParseError> { + if input.len() < min_len { + return Err(TrackerParseError::InvalidUdpPacket(format!( + "{label} truncated: expected at least {min_len} bytes but got {}", + input.len() + ))); + } + Ok(()) +} + +/// Reads one big-endian `u32` from a UDP tracker payload. +fn read_u32_be(input: &[u8], offset: usize, field: &str) -> Result { + let end = offset.saturating_add(4); + let bytes = input + .get(offset..end) + .ok_or_else(|| TrackerParseError::InvalidUdpPacket(format!("{field} truncated")))?; + let mut out = [0_u8; 4]; + out.copy_from_slice(bytes); + Ok(u32::from_be_bytes(out)) +} + +/// Reads one big-endian `u64` from a UDP tracker payload. +fn read_u64_be(input: &[u8], offset: usize, field: &str) -> Result { + let end = offset.saturating_add(8); + let bytes = input + .get(offset..end) + .ok_or_else(|| TrackerParseError::InvalidUdpPacket(format!("{field} truncated")))?; + let mut out = [0_u8; 8]; + out.copy_from_slice(bytes); + Ok(u64::from_be_bytes(out)) +} diff --git a/crates/aria2-rust-pro-protocol/src/transport.rs b/crates/aria2-rust-pro-protocol/src/transport.rs new file mode 100644 index 0000000..88105de --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/transport.rs @@ -0,0 +1,21 @@ +//! Generic transport request and response models plus connector traits. +#![forbid(unsafe_code)] + +/// Generic transport-layer request, response, and error models. +mod model; +/// Standard library backed connector implementations. +mod std_connectors; + +#[cfg(test)] +mod tests; + +pub use self::model::{ + HttpTransportConnector, PeerWireTransportConnector, PeerWireTransportRequest, + PeerWireTransportResponse, TransportBody, TransportConnector, TransportEndpoint, + TransportError, TransportErrorContext, TransportErrorKind, TransportRequest, TransportResponse, + TransportResult, TransportScheme, TransportStream, UdpTransportConnector, UdpTransportRequest, + UdpTransportResponse, +}; +pub use self::std_connectors::{ + StdDhtTransport, StdTcpPeerWireTransportConnector, StdUdpTransportConnector, +}; diff --git a/crates/aria2-rust-pro-protocol/src/transport/model.rs b/crates/aria2-rust-pro-protocol/src/transport/model.rs new file mode 100644 index 0000000..4de1275 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/transport/model.rs @@ -0,0 +1,246 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +use crate::http::{HttpHeader, HttpRequestModel, HttpResponseModel}; + +/// Transport schemes recognized by the protocol layer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportScheme { + /// Plain HTTP. + Http, + /// HTTP over TLS. + Https, + /// FTP control/data channels. + Ftp, + /// SFTP over SSH. + Sftp, + /// Metalink document fetches. + Metalink, + /// `BitTorrent` peer or metadata exchanges. + BitTorrent, + /// Magnet URI bootstrap requests. + Magnet, + /// Local file operations. + File, +} + +/// Resolved network or file endpoint targeted by a transport request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportEndpoint { + /// Scheme used to interpret the address. + pub scheme: TransportScheme, + /// Opaque address string such as a URL, socket address, or path. + pub address: String, +} + +/// Generic transport body payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransportBody { + /// No payload. + Empty, + /// In-memory payload bytes. + Inline(Vec), + /// Streamed payload with an optional expected length. + Stream { + /// Declared payload length when known in advance. + expected_len: Option, + }, +} + +/// Generic transport request shared across protocol adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportRequest { + /// Destination endpoint. + pub endpoint: TransportEndpoint, + /// Transport headers represented with the HTTP header model. + pub headers: Vec, + /// Request body payload. + pub body: TransportBody, +} + +/// Generic transport response shared across protocol adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportResponse { + /// Optional response status code when the transport exposes one. + pub status: Option, + /// Response headers represented with the HTTP header model. + pub headers: Vec, + /// Response body payload. + pub body: TransportBody, +} + +/// Classified transport failure kinds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportErrorKind { + /// The requested scheme is not supported by the connector. + UnsupportedScheme, + /// The connector has not established a usable session. + NotConnected, + /// The operation timed out. + Timeout, + /// The operation would block and should be retried later. + WouldBlock, + /// The remote side reset the connection. + ConnectionReset, + /// The peer returned malformed data or violated the protocol contract. + ProtocolViolation, + /// DNS resolution failed. + DnsFailed, + /// TLS negotiation or validation failed. + TlsFailed, + /// Authentication failed. + AuthenticationFailed, + /// Proxy negotiation failed. + ProxyFailed, + /// Checksum verification failed. + ChecksumMismatch, + /// A generic I/O error occurred. + Io, +} + +/// Optional context attached to a transport error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportErrorContext { + /// Endpoint involved in the failure. + pub endpoint: Option, + /// Request payload involved in the failure. + pub request: Option, + /// Partial response captured before the failure, if any. + pub response: Option, +} + +/// Error value returned by transport connectors. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportError { + /// Coarse failure classification. + pub kind: TransportErrorKind, + /// Human-readable error message. + pub message: String, + /// Optional stringified source error. + pub source: Option, + /// Optional structured request/response context. + pub context: Option, +} + +impl Display for TransportError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl Error for TransportError {} + +/// Result wrapper used by some protocol adapters that carry value-or-error explicitly. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportResult { + /// Successful value when the operation completed. + pub value: Option, + /// Failure when the operation did not complete successfully. + pub error: Option, +} + +impl TransportResult { + /// Builds a successful transport result. + #[must_use] + pub const fn ok(value: T) -> Self { + Self { + value: Some(value), + error: None, + } + } + + /// Builds an error transport result with no additional context. + #[must_use] + pub fn err(kind: TransportErrorKind, message: impl Into) -> Self { + Self { + value: None, + error: Some(TransportError { + kind, + message: message.into(), + source: None, + context: None, + }), + } + } +} + +/// Minimal lifecycle contract for persistent transport streams. +pub trait TransportStream { + /// Returns whether the stream is currently open. + fn is_open(&self) -> bool; + /// Closes the stream and releases any associated resources. + fn close(&self) -> Result<(), TransportError>; +} + +/// Generic connector contract for request/response transports. +pub trait TransportConnector { + /// Sends a transport request and returns a transport response. + fn connect(&self, request: &TransportRequest) -> Result; +} + +/// Specialized connector contract for HTTP request execution. +pub trait HttpTransportConnector { + /// Executes an HTTP request and returns the protocol-layer response model. + fn connect_http(&self, request: &HttpRequestModel) + -> Result; +} + +/// Datagram request wrapper for tracker and DHT exchanges. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UdpTransportRequest { + /// Remote endpoint to send to. + pub endpoint: TransportEndpoint, + /// Datagram payload bytes. + pub payload: Vec, +} + +/// Datagram response wrapper for tracker and DHT exchanges. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UdpTransportResponse { + /// Endpoint that produced the payload. + pub endpoint: TransportEndpoint, + /// Datagram payload bytes. + pub payload: Vec, +} + +/// Connector contract for UDP datagram transports. +pub trait UdpTransportConnector { + /// Sends a UDP datagram and returns the response payload. + fn send_udp( + &self, + request: &UdpTransportRequest, + ) -> Result; +} + +/// Request wrapper for `BitTorrent` peer-wire exchanges. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireTransportRequest { + /// Remote peer endpoint. + pub endpoint: TransportEndpoint, + /// Info hash associated with the peer session. + pub info_hash: Vec, + /// Local peer identifier. + pub peer_id: Vec, + /// Peer-wire payload bytes. + pub payload: Vec, +} + +/// Response wrapper for `BitTorrent` peer-wire exchanges. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerWireTransportResponse { + /// Endpoint that produced the payload. + pub endpoint: TransportEndpoint, + /// Peer-wire payload bytes. + pub payload: Vec, +} + +/// Connector contract for `BitTorrent` peer-wire transports. +pub trait PeerWireTransportConnector { + /// Executes a peer-wire request and returns the peer response payload. + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result; +} diff --git a/crates/aria2-rust-pro-protocol/src/transport/std_connectors.rs b/crates/aria2-rust-pro-protocol/src/transport/std_connectors.rs new file mode 100644 index 0000000..dab83bd --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/transport/std_connectors.rs @@ -0,0 +1,518 @@ +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, TcpStream, ToSocketAddrs, UdpSocket}, + time::Duration, +}; + +use crate::{ + torrent::DhtMessageModel, + tracker::{DhtNodeModel, DhtTransport}, +}; + +use super::model::{ + PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, + TransportEndpoint, TransportError, TransportErrorContext, TransportErrorKind, TransportScheme, + UdpTransportConnector, UdpTransportRequest, UdpTransportResponse, +}; + +/// Default blocking socket timeout used by protocol transports. +const DEFAULT_SOCKET_TIMEOUT: Duration = Duration::from_secs(5); +/// Default maximum UDP datagram size accepted by the loopback connector. +const DEFAULT_MAX_DATAGRAM_SIZE: usize = 65_535; +/// Default maximum peer-wire response budget accepted from one exchange. +const DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES: usize = 1_048_576; + +/// Blocking stdlib UDP connector for tracker- and DHT-style datagram exchanges. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StdUdpTransportConnector { + /// Blocking read/write timeout applied to the underlying UDP socket. + timeout: Duration, + /// Maximum response payload size accepted from one datagram exchange. + max_datagram_size: usize, +} + +impl StdUdpTransportConnector { + /// Creates a UDP connector with conservative blocking timeouts. + #[must_use] + pub const fn new() -> Self { + Self { + timeout: DEFAULT_SOCKET_TIMEOUT, + max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE, + } + } + + /// Creates a UDP connector with the provided read/write timeout. + #[must_use] + pub const fn with_timeout(timeout: Duration) -> Self { + Self { + timeout, + max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE, + } + } + + /// Creates a UDP connector with explicit timeout and receive buffer sizing. + #[must_use] + pub const fn with_config(timeout: Duration, max_datagram_size: usize) -> Self { + Self { + timeout, + max_datagram_size: if max_datagram_size == 0 { + 1 + } else { + max_datagram_size + }, + } + } +} + +impl Default for StdUdpTransportConnector { + fn default() -> Self { + Self::new() + } +} + +impl UdpTransportConnector for StdUdpTransportConnector { + fn send_udp( + &self, + request: &UdpTransportRequest, + ) -> Result { + let remote = + resolve_first_socket_addr(&request.endpoint.address, request.endpoint.clone())?; + let socket = bind_udp_socket(&remote, request.endpoint.clone())?; + socket + .set_read_timeout(Some(self.timeout)) + .map_err(|error| { + io_transport_error( + "failed to set udp read timeout", + error, + Some(request.endpoint.clone()), + ) + })?; + socket + .set_write_timeout(Some(self.timeout)) + .map_err(|error| { + io_transport_error( + "failed to set udp write timeout", + error, + Some(request.endpoint.clone()), + ) + })?; + socket.connect(remote).map_err(|error| { + io_transport_error( + "failed to connect udp socket", + error, + Some(request.endpoint.clone()), + ) + })?; + socket.send(&request.payload).map_err(|error| { + io_transport_error( + "failed to send udp datagram", + error, + Some(request.endpoint.clone()), + ) + })?; + + let mut buffer = vec![0_u8; self.max_datagram_size]; + let read = socket.recv(&mut buffer).map_err(|error| { + io_transport_error( + "failed to receive udp datagram", + error, + Some(request.endpoint.clone()), + ) + })?; + buffer.truncate(read); + + Ok(UdpTransportResponse { + endpoint: TransportEndpoint { + scheme: request.endpoint.scheme, + address: remote.to_string(), + }, + payload: buffer, + }) + } +} + +/// Blocking DHT transport backed by one-shot UDP sockets. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StdDhtTransport { + /// UDP transport used to execute one-shot DHT datagram exchanges. + udp: StdUdpTransportConnector, +} + +impl StdDhtTransport { + /// Creates a DHT transport with conservative blocking timeouts. + #[must_use] + pub const fn new() -> Self { + Self { + udp: StdUdpTransportConnector::new(), + } + } + + /// Creates a DHT transport with the provided request timeout. + #[must_use] + pub const fn with_timeout(timeout: Duration) -> Self { + Self { + udp: StdUdpTransportConnector::with_timeout(timeout), + } + } + + /// Wraps an explicit UDP connector for DHT request/response exchanges. + #[must_use] + pub const fn with_udp_connector(udp: StdUdpTransportConnector) -> Self { + Self { udp } + } +} + +impl Default for StdDhtTransport { + fn default() -> Self { + Self::new() + } +} + +impl DhtTransport for StdDhtTransport { + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + let endpoint = TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: format_socket_endpoint(&node.address, node.port), + }; + let response = self.udp.send_udp(&UdpTransportRequest { + endpoint: endpoint.clone(), + payload: message.to_bencode_bytes(), + })?; + DhtMessageModel::from_bencode_bytes(&response.payload).map_err(|error| TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: format!("failed to parse dht response: {error}"), + source: Some(error), + context: Some(TransportErrorContext { + endpoint: Some(endpoint), + request: None, + response: None, + }), + }) + } +} + +/// Blocking peer-wire connector backed by stdlib TCP streams. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StdTcpPeerWireTransportConnector { + /// Timeout budget for establishing the TCP connection. + connect_timeout: Duration, + /// Blocking read/write timeout applied after the connection opens. + io_timeout: Duration, + /// Maximum response size accepted from one peer-wire exchange. + max_response_bytes: usize, +} + +impl StdTcpPeerWireTransportConnector { + /// Creates a peer-wire connector with conservative blocking timeouts. + #[must_use] + pub const fn new() -> Self { + Self { + connect_timeout: DEFAULT_SOCKET_TIMEOUT, + io_timeout: DEFAULT_SOCKET_TIMEOUT, + max_response_bytes: DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES, + } + } + + /// Creates a peer-wire connector with explicit connect and I/O timeouts. + #[must_use] + pub const fn with_timeouts(connect_timeout: Duration, io_timeout: Duration) -> Self { + Self { + connect_timeout, + io_timeout, + max_response_bytes: DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES, + } + } + + /// Creates a peer-wire connector with fully explicit limits. + #[must_use] + pub const fn with_config( + connect_timeout: Duration, + io_timeout: Duration, + max_response_bytes: usize, + ) -> Self { + Self { + connect_timeout, + io_timeout, + max_response_bytes: if max_response_bytes == 0 { + 1 + } else { + max_response_bytes + }, + } + } +} + +impl Default for StdTcpPeerWireTransportConnector { + fn default() -> Self { + Self::new() + } +} + +impl PeerWireTransportConnector for StdTcpPeerWireTransportConnector { + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + if request.endpoint.scheme != TransportScheme::BitTorrent { + return Err(TransportError { + kind: TransportErrorKind::UnsupportedScheme, + message: format!( + "peer-wire tcp connector only supports bittorrent endpoints, got {:?}", + request.endpoint.scheme + ), + source: None, + context: Some(TransportErrorContext { + endpoint: Some(request.endpoint.clone()), + request: None, + response: None, + }), + }); + } + + let (mut stream, remote) = + connect_tcp_stream(&request.endpoint, self.connect_timeout, self.io_timeout)?; + stream.write_all(&request.payload).map_err(|error| { + io_transport_error( + "failed to write peer-wire payload", + error, + Some(request.endpoint.clone()), + ) + })?; + stream.flush().map_err(|error| { + io_transport_error( + "failed to flush peer-wire payload", + error, + Some(request.endpoint.clone()), + ) + })?; + + let mut response = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + response.extend_from_slice(&buffer[..read]); + if response.len() > self.max_response_bytes { + return Err(TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: format!( + "peer-wire response exceeded {} bytes", + self.max_response_bytes + ), + source: None, + context: Some(TransportErrorContext { + endpoint: Some(request.endpoint.clone()), + request: None, + response: None, + }), + }); + } + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if response.is_empty() { + return Err(io_transport_error( + "timed out waiting for peer-wire response", + error, + Some(request.endpoint.clone()), + )); + } + break; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => { + return Err(io_transport_error( + "failed to read peer-wire response", + error, + Some(request.endpoint.clone()), + )); + } + } + } + + if response.is_empty() { + return Err(TransportError { + kind: TransportErrorKind::ConnectionReset, + message: "peer-wire peer closed connection without a response".to_owned(), + source: None, + context: Some(TransportErrorContext { + endpoint: Some(request.endpoint.clone()), + request: None, + response: None, + }), + }); + } + + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: request.endpoint.scheme, + address: remote.to_string(), + }, + payload: response, + }) + } +} + +/// Binds a wildcard UDP socket that matches the remote address family. +fn bind_udp_socket( + remote: &SocketAddr, + endpoint: TransportEndpoint, +) -> Result { + let bind_addr = if remote.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + UdpSocket::bind(bind_addr) + .map_err(|error| io_transport_error("failed to bind udp socket", error, Some(endpoint))) +} + +/// Resolves and connects a TCP stream to the first reachable peer endpoint. +fn connect_tcp_stream( + endpoint: &TransportEndpoint, + connect_timeout: Duration, + io_timeout: Duration, +) -> Result<(TcpStream, SocketAddr), TransportError> { + let resolved = resolve_socket_addrs(&endpoint.address, endpoint.clone())?; + let mut last_error = None; + for address in resolved { + match TcpStream::connect_timeout(&address, connect_timeout) { + Ok(stream) => { + stream.set_nodelay(true).map_err(|error| { + io_transport_error( + "failed to enable tcp nodelay for peer-wire stream", + error, + Some(endpoint.clone()), + ) + })?; + stream.set_read_timeout(Some(io_timeout)).map_err(|error| { + io_transport_error( + "failed to set peer-wire read timeout", + error, + Some(endpoint.clone()), + ) + })?; + stream + .set_write_timeout(Some(io_timeout)) + .map_err(|error| { + io_transport_error( + "failed to set peer-wire write timeout", + error, + Some(endpoint.clone()), + ) + })?; + return Ok((stream, address)); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.map_or_else( + || TransportError { + kind: TransportErrorKind::DnsFailed, + message: format!("no socket addresses resolved for {}", endpoint.address), + source: None, + context: Some(TransportErrorContext { + endpoint: Some(endpoint.clone()), + request: None, + response: None, + }), + }, + |error| { + io_transport_error( + "failed to connect peer-wire tcp stream", + error, + Some(endpoint.clone()), + ) + }, + )) +} + +/// Resolves one socket address and returns the first candidate. +fn resolve_first_socket_addr( + address: &str, + endpoint: TransportEndpoint, +) -> Result { + resolve_socket_addrs(address, endpoint)? + .into_iter() + .next() + .ok_or_else(|| TransportError { + kind: TransportErrorKind::DnsFailed, + message: format!("no socket addresses resolved for {address}"), + source: None, + context: None, + }) +} + +/// Resolves all socket-address candidates for a host and port string. +fn resolve_socket_addrs( + address: &str, + endpoint: TransportEndpoint, +) -> Result, TransportError> { + let iter = address.to_socket_addrs().map_err(|error| TransportError { + kind: TransportErrorKind::DnsFailed, + message: format!("failed to resolve socket address {address}: {error}"), + source: Some(error.to_string()), + context: Some(TransportErrorContext { + endpoint: Some(endpoint), + request: None, + response: None, + }), + })?; + Ok(iter.collect()) +} + +/// Wraps a low-level I/O error into the protocol-layer transport error model. +fn io_transport_error( + message: &str, + error: io::Error, + endpoint: Option, +) -> TransportError { + TransportError { + kind: map_io_error_kind(&error), + message: format!("{message}: {error}"), + source: Some(error.to_string()), + context: Some(TransportErrorContext { + endpoint, + request: None, + response: None, + }), + } +} + +/// Maps stdlib I/O error kinds into protocol transport categories. +fn map_io_error_kind(error: &io::Error) -> TransportErrorKind { + match error.kind() { + io::ErrorKind::TimedOut => TransportErrorKind::Timeout, + io::ErrorKind::WouldBlock => TransportErrorKind::WouldBlock, + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotConnected + | io::ErrorKind::AddrNotAvailable + | io::ErrorKind::HostUnreachable + | io::ErrorKind::NetworkUnreachable => TransportErrorKind::NotConnected, + io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::BrokenPipe + | io::ErrorKind::UnexpectedEof => TransportErrorKind::ConnectionReset, + io::ErrorKind::InvalidData => TransportErrorKind::ProtocolViolation, + _ => TransportErrorKind::Io, + } +} + +/// Formats a socket endpoint as `host:port` or `[ipv6]:port`. +fn format_socket_endpoint(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} diff --git a/crates/aria2-rust-pro-protocol/src/transport/tests.rs b/crates/aria2-rust-pro-protocol/src/transport/tests.rs new file mode 100644 index 0000000..b6bc086 --- /dev/null +++ b/crates/aria2-rust-pro-protocol/src/transport/tests.rs @@ -0,0 +1,192 @@ +use std::{ + io::{Read, Write}, + net::{TcpListener, UdpSocket}, + thread, + time::Duration, +}; + +use crate::{ + torrent::{DhtMessageBody, DhtMessageModel, DhtResponseModel, PeerWireHandshakeModel}, + tracker::{DhtNodeModel, DhtTransport}, +}; + +use super::*; + +struct LoopbackUdpConnector; + +impl UdpTransportConnector for LoopbackUdpConnector { + fn send_udp( + &self, + request: &UdpTransportRequest, + ) -> Result { + Ok(UdpTransportResponse { + endpoint: request.endpoint.clone(), + payload: request.payload.clone(), + }) + } +} + +struct LoopbackPeerWireConnector; + +impl PeerWireTransportConnector for LoopbackPeerWireConnector { + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + let mut echoed = request.info_hash.clone(); + echoed.extend_from_slice(&request.peer_id); + echoed.extend_from_slice(&request.payload); + Ok(PeerWireTransportResponse { + endpoint: request.endpoint.clone(), + payload: echoed, + }) + } +} + +#[test] +fn transport_result_error_builder_populates_error_shape() { + let result = TransportResult::<()>::err(TransportErrorKind::Timeout, "timed out"); + assert!(result.value.is_none()); + assert_eq!( + result.error, + Some(TransportError { + kind: TransportErrorKind::Timeout, + message: "timed out".to_owned(), + source: None, + context: None, + }) + ); +} + +#[test] +fn loopback_udp_connector_round_trips_payload() { + let connector = LoopbackUdpConnector; + let request = UdpTransportRequest { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: "127.0.0.1:6969".to_owned(), + }, + payload: vec![0, 1, 2, 3, 4], + }; + + let response = connector + .send_udp(&request) + .expect("udp transport should echo"); + assert_eq!(response.endpoint, request.endpoint); + assert_eq!(response.payload, request.payload); +} + +#[test] +fn loopback_peer_wire_connector_round_trips_handshake_material() { + let connector = LoopbackPeerWireConnector; + let request = PeerWireTransportRequest { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: "192.0.2.10:51413".to_owned(), + }, + info_hash: vec![0x11; 20], + peer_id: vec![0x22; 20], + payload: vec![0x13, b'B', b'i', b't'], + }; + + let response = connector + .connect_peer_wire(&request) + .expect("peer-wire transport should echo"); + assert_eq!(response.endpoint, request.endpoint); + assert_eq!(response.payload.len(), 44); + assert!(response.payload.starts_with(&request.info_hash)); +} + +#[test] +fn std_dht_transport_sends_live_udp_bencoded_messages() { + let socket = UdpSocket::bind("127.0.0.1:0").expect("udp listener should bind"); + let addr = socket + .local_addr() + .expect("udp listener should expose addr"); + let handle = thread::spawn(move || { + let mut buffer = [0_u8; 2048]; + let (read, peer) = socket + .recv_from(&mut buffer) + .expect("udp request should arrive"); + let request = DhtMessageModel::from_bencode_bytes(&buffer[..read]) + .expect("incoming dht message should parse"); + assert_eq!(request.method(), Some("ping")); + let response = DhtMessageModel::ping_response(request.transaction_id, vec![0x44; 20]) + .to_bencode_bytes(); + socket + .send_to(&response, peer) + .expect("udp response should write"); + }); + + let transport = StdDhtTransport::with_timeout(Duration::from_secs(1)); + let response = transport + .send_message( + &DhtNodeModel { + node_id: String::new(), + address: "127.0.0.1".to_owned(), + port: addr.port(), + }, + &DhtMessageModel::ping_query(b"pi".to_vec(), vec![0x11; 20]), + ) + .expect("live dht transport should round-trip"); + + assert_eq!(response.transaction_id, b"pi".to_vec()); + assert!(matches!( + response.body, + DhtMessageBody::Response(DhtResponseModel::Ping(_)) + )); + handle.join().expect("udp server thread should join"); +} + +#[test] +fn std_tcp_peer_wire_connector_executes_live_exchange() { + let listener = TcpListener::bind("127.0.0.1:0").expect("tcp listener should bind"); + let addr = listener + .local_addr() + .expect("tcp listener should expose addr"); + let info_hash = [0x11; 20]; + let local_peer_id = [0x22; 20]; + let remote_peer_id = [0x33; 20]; + let request_payload = PeerWireHandshakeModel::new(info_hash, local_peer_id).serialize(); + let request_len = request_payload.len(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("client should connect"); + let mut received = vec![0_u8; request_len]; + stream + .read_exact(&mut received) + .expect("peer-wire request should read"); + let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&received) + .expect("request handshake should parse"); + assert_eq!(consumed, request_len); + assert_eq!(handshake.info_hash, info_hash); + assert_eq!(handshake.peer_id, local_peer_id); + + let response = PeerWireHandshakeModel::new(info_hash, remote_peer_id).serialize(); + stream + .write_all(&response) + .expect("peer-wire response should write"); + }); + + let connector = StdTcpPeerWireTransportConnector::with_timeouts( + Duration::from_secs(1), + Duration::from_secs(1), + ); + let response = connector + .connect_peer_wire(&PeerWireTransportRequest { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: addr.to_string(), + }, + info_hash: info_hash.to_vec(), + peer_id: local_peer_id.to_vec(), + payload: request_payload, + }) + .expect("live peer-wire connector should exchange handshake"); + + let handshake = + PeerWireHandshakeModel::parse(&response.payload).expect("response handshake should parse"); + assert_eq!(handshake.info_hash, info_hash); + assert_eq!(handshake.peer_id, remote_peer_id); + handle.join().expect("tcp server thread should join"); +} diff --git a/crates/aria2-rust-pro-rpc/Cargo.toml b/crates/aria2-rust-pro-rpc/Cargo.toml new file mode 100644 index 0000000..433f7a9 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "aria2-rust-pro-rpc" +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_rpc" +path = "src/lib.rs" + +[dependencies] +aria2-rust-pro-compat.workspace = true +aria2-rust-pro-core.workspace = true +aria2-rust-pro-protocol.workspace = true +base64 = "0.22" +serde_json = "1" +sha1 = "0.10" + +[dev-dependencies] +aria2-rust-pro-storage.workspace = true + +[lints] +workspace = true diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher.rs b/crates/aria2-rust-pro-rpc/src/dispatcher.rs new file mode 100644 index 0000000..8bb5bff --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher.rs @@ -0,0 +1,419 @@ +//! In-process RPC dispatcher backed by the download engine. +#![expect( + clippy::arithmetic_side_effects, + clippy::assigning_clones, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::clone_on_copy, + clippy::cognitive_complexity, + clippy::doc_markdown, + clippy::float_arithmetic, + clippy::format_collect, + clippy::format_push_string, + clippy::if_not_else, + clippy::indexing_slicing, + clippy::integer_division, + clippy::into_iter_on_ref, + clippy::map_unwrap_or, + clippy::manual_clamp, + clippy::match_same_arms, + clippy::missing_const_for_fn, + clippy::missing_errors_doc, + clippy::needless_collect, + clippy::needless_pass_by_value, + clippy::option_if_let_else, + clippy::redundant_clone, + clippy::redundant_closure_for_method_calls, + clippy::too_many_arguments, + clippy::too_many_lines, + clippy::trivially_copy_pass_by_ref, + clippy::unused_self, + unused_imports, + reason = "the aria2-compatible dispatcher is a monolithic compatibility and test surface where these lints add large volumes of noise without changing validated behavior" +)] + +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use aria2_rust_pro_core::{ + BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, BtTrackerInfo, CoreError, + DownloadEngine, DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId, + PieceMap, PieceState, QueuePositionMode, RequestGroup, RuntimeConfig, SaveSessionTarget, +}; +use aria2_rust_pro_protocol::{ + DhtMessageModel, DhtNodeModel, DhtTransport, HeaderKind, HttpResponseModel, MagnetUriModel, + ResponseBody, TorrentMetadataModel, TrackerRequestModel, TrackerScrapeModel, TrackerTransport, + magnet::MagnetBootstrapModel, + parse_torrent_metadata, + torrent::{ + DhtMessageBody, DhtResponseModel, PeerWireBlockRequestModel, + PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind, + PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel, + TorrentMessageModel, + }, + transport::{ + PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, + TransportEndpoint, TransportScheme, + }, +}; +use sha1::{Digest, Sha1}; + +use crate::{ + handlers::RpcHandlerContext, + jsonrpc::{JsonRpcRequest, JsonRpcResponse}, + model::{BT_STATUS_FIELDS, RpcAuthContext, RpcError, RpcMeta, RpcValue}, + router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter}, + xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue}, +}; + +/// In-process dispatcher that routes aria2-compatible RPC calls into the core engine. +pub struct InProcessRpcDispatcher { + /// Core download engine that owns the live download registry and runtime state. + engine: DownloadEngine, + /// Extension router for non-core RPC methods layered on top of aria2 compatibility. + router: RpcRouter, + /// Stable session identifier returned by `aria2.getSessionInfo`. + session_id: String, +} + +/// Lightweight per-download state used by internal callers that do not need a +/// full `aria2.tellStatus` payload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RpcStatusSummary { + /// Current user-visible lifecycle state. + pub status: DownloadStatus, + /// Expected payload length in bytes. + pub total_length: u64, + /// Completed payload length in bytes. + pub completed_length: u64, + /// Active connection count tracked for the download. + pub connections: u32, + /// Whether the download currently carries BitTorrent runtime state. + pub is_bt: bool, +} + +/// BitTorrent runtime orchestration helpers used by RPC compatibility methods. +mod bt_runtime; +/// Shared compatibility-side runtime helpers for BitTorrent and related RPC shaping. +mod compat_support; +/// Top-level JSON-RPC and XML-RPC request ingress helpers. +mod dispatch_surface; +/// Shared JSON-RPC and XML-RPC fault shaping helpers. +mod faults; +/// Shared parser and conversion helpers for dispatcher methods. +mod helpers; +/// Mutating aria2-compatible RPC method implementations. +mod mutations; +/// Status/view payload builders for aria2-compatible RPC responses. +mod payloads; +/// Read-only aria2-compatible RPC method implementations. +mod queries; +/// Transfer registration and writeback helpers for add* RPC methods. +mod transfer_runtime; + +use self::bt_runtime::{ + BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot, + BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus, +}; +use self::compat_support::{ + BT_METADATA_PIECE_LENGTH, PeerWireExchangePlan, apply_bt_select_file_option, + bt_metadata_piece_count, bt_metadata_piece_span, bt_peer_is_connectable, bt_peer_metadata_key, + bt_piece_count, bt_piece_span_bytes, bt_runtime_total_length, bt_verified_length, + build_bt_runtime_state, build_bt_runtime_state_from_magnet, build_dht_announce_peer_request, + build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request, + build_peer_wire_exchange_plan, build_tracker_request, decode_hex_nibble, + decode_hex_string_exact, default_bt_dht_nodes, generate_session_id, hex_string, + initial_bt_dht_nodes, merge_bt_dht_nodes, merge_bt_peers, merge_bt_trackers, + parse_bt_select_file_indexes, parse_dht_compact_nodes, parse_dht_compact_peers, + parse_dht_node_spec, parse_peer_wire_exchange_response, peer_wire_bitfield_is_complete, + pick_bt_dht_node, promote_bt_dht_node, push_bt_runtime_coordinator_result, + resolve_bt_info_hash, rpc_bt_info_hash, rpc_bt_local_node_id, rpc_enabled_features, + rpc_share_ratio_text, rpc_share_time_text, skipped_bt_runtime_coordinator_step, + try_promote_bt_metadata, verified_length_for_range, +}; +use self::{ + faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value}, + helpers::{ + apply_group_options, decode_metalink_payload, filter_status_payload, + first_forbidden_change_global_option_key, first_forbidden_change_option_key, + i64_from_usize, is_retry_relevant_status, is_rpc_uri_candidate, metalink_default_options, + option_specs_for_global_view, parse_content_range_completed_length, + parse_optional_option_object, parse_optional_position, parse_optional_status_keys, + parse_optional_uri_array, parse_required_file_index, parse_uri_array_allow_empty, + parse_uri_list_param, rpc_uri_file_name, rpc_uri_has_ascii_prefix, + rpc_uri_has_ascii_suffix, slice_handles_by_offset, u32_from_usize, u64_from_usize, + usize_from_i64, usize_from_u64, xmlrpc_member_value, + }, +}; +impl std::fmt::Debug for InProcessRpcDispatcher { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("InProcessRpcDispatcher") + .field("tracked_download_count", &self.tracked_download_count()) + .field("session_id", &self.session_id) + .finish_non_exhaustive() + } +} + +impl Default for InProcessRpcDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl InProcessRpcDispatcher { + #[must_use] + /// Creates a dispatcher using the default runtime configuration. + pub fn new() -> Self { + Self::with_runtime(RuntimeConfig::default()) + } + + #[must_use] + /// Creates a dispatcher using an explicit runtime configuration. + pub fn with_runtime(runtime: RuntimeConfig) -> Self { + Self { + engine: DownloadEngine::with_runtime(runtime), + router: RpcRouter::new(), + session_id: generate_session_id(), + } + } + + #[must_use] + /// Returns the number of tracked downloads currently known to the engine. + pub fn tracked_download_count(&self) -> usize { + self.engine.task_count() + } + + /// Returns a lightweight summary for one tracked download without shaping a + /// full RPC payload. + pub fn status_summary_for_gid(&self, gid: &str) -> Result { + let gid = parse_gid_text(gid)?; + let group = self + .engine + .registry() + .get(gid) + .ok_or_else(|| missing_download_error(gid))?; + Ok(RpcStatusSummary { + status: *group.status(), + total_length: group.total_length(), + completed_length: group.completed_length(), + connections: group.num_connections(), + is_bt: group.bt().is_some(), + }) + } + + /// Registers a runtime listener with the underlying download engine. + pub fn register_runtime_listener( + &mut self, + listener: impl aria2_rust_pro_core::EventListener + 'static, + ) { + self.engine.register_listener(listener); + } +} + +/// Maps core state-transition failures into aria2-compatible RPC errors. +fn state_transition_rpc_error( + method: &'static str, + gid: DownloadId, + error: &CoreError, +) -> RpcError { + match method { + "aria2.pause" | "aria2.forcePause" => { + RpcError::unsupported(&format!("GID#{gid} cannot be paused now")) + } + "aria2.unpause" => RpcError::unsupported(&format!("GID#{gid} cannot be unpaused now")), + "aria2.remove" | "aria2.forceRemove" => match error { + CoreError::UnknownDownloadId(_) => { + RpcError::unsupported(&format!("Active Download not found for GID#{gid}")) + } + _ => RpcError::unsupported(&format!("GID#{gid} cannot be removed now")), + }, + _ => RpcError::unsupported(&error.to_string()), + } +} + +/// Parses an RPC GID string into the engine download identifier. +fn parse_gid_text(gid: &str) -> Result { + DownloadId::parse_hex(gid).ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}"))) +} + +/// Builds the upstream-style missing-download error for a GID. +fn missing_download_error(gid: DownloadId) -> RpcError { + RpcError::unsupported(&format!("No such download for GID#{gid}")) +} + +/// Infers the effective transfer length from an HTTP response snapshot. +fn http_response_length(response: &HttpResponseModel) -> Option { + response + .content_range + .as_ref() + .and_then(|range| range.total_size) + .or_else(|| { + response + .headers + .headers + .iter() + .find(|header| { + header.kind == HeaderKind::Response + && header.name.eq_ignore_ascii_case("content-length") + }) + .and_then(|header| header.value.parse::().ok()) + }) + .or_else(|| match &response.body { + ResponseBody::Inline(bytes) => Some(bytes.len() as u64), + ResponseBody::Streamed { + expected_len, + observed_len, + .. + } => observed_len.or(*expected_len), + ResponseBody::Empty => Some(0), + }) +} + +/// Infers the bytes transferred during the latest HTTP response chunk. +fn http_response_delta_length(response: &HttpResponseModel) -> Option { + match &response.body { + ResponseBody::Inline(bytes) => Some(bytes.len() as u64), + ResponseBody::Streamed { observed_len, .. } => *observed_len, + ResponseBody::Empty => Some(0), + } +} + +/// Infers the cumulative completed length represented by an HTTP response. +fn http_response_completed_length(response: &HttpResponseModel) -> Option { + let observed_len = match &response.body { + ResponseBody::Inline(bytes) => Some(bytes.len() as u64), + ResponseBody::Streamed { observed_len, .. } => *observed_len, + ResponseBody::Empty => Some(0), + }; + response + .content_range + .as_ref() + .map(|range| { + observed_len + .map(|len| range.start.saturating_add(len)) + .unwrap_or_else(|| range.end_inclusive.saturating_add(1)) + }) + .or_else(|| { + response + .headers + .headers + .iter() + .find(|header| { + header.kind == HeaderKind::Response + && header.name.eq_ignore_ascii_case("content-range") + }) + .and_then(|header| parse_content_range_completed_length(&header.value)) + }) + .or(observed_len) +} + +#[cfg(test)] +mod response_length_tests { + use super::{http_response_completed_length, http_response_delta_length}; + use aria2_rust_pro_protocol::{ + ChecksumSpec, ContentRangeSpec, HeaderKind, HttpHeader, HttpResponseHeaders, + HttpResponseModel, HttpVersion, RangeUnit, ResponseBody, + }; + + #[test] + fn streamed_lengths_prefer_observed_bytes_over_declared_content_length() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: vec![HttpHeader { + name: "Content-Length".to_owned(), + value: "10".to_owned(), + kind: HeaderKind::Response, + }], + }, + body: ResponseBody::Streamed { + expected_len: Some(10), + observed_len: Some(0), + observed_digest: None, + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: Some(ChecksumSpec { + algorithm: "sha-256".to_owned(), + expected_hex: String::new(), + actual_hex: None, + }), + redirected_from: None, + }; + + assert_eq!(http_response_delta_length(&response), Some(0)); + assert_eq!(http_response_completed_length(&response), Some(0)); + } + + #[test] + fn streamed_partial_completion_uses_observed_span_not_declared_range_tail() { + let response = 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: None, + temp_path: None, + }, + content_range: Some(ContentRangeSpec { + unit: RangeUnit::Bytes, + start: 0, + end_inclusive: 9, + total_size: Some(10), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + + assert_eq!(http_response_delta_length(&response), Some(5)); + assert_eq!(http_response_completed_length(&response), Some(5)); + } + + #[test] + fn streamed_lengths_without_observed_bytes_do_not_claim_progress() { + let response = HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: vec![HttpHeader { + name: "Content-Length".to_owned(), + value: "4096".to_owned(), + kind: HeaderKind::Response, + }], + }, + body: ResponseBody::Streamed { + expected_len: Some(4096), + observed_len: None, + observed_digest: None, + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + assert_eq!(http_response_delta_length(&response), None); + assert_eq!(http_response_completed_length(&response), None); + } +} + +#[cfg(test)] +/// Regression coverage for JSON-RPC, XML-RPC, and queue mutation dispatcher behavior. +mod tests; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime.rs new file mode 100644 index 0000000..fb1dede --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime.rs @@ -0,0 +1,31 @@ +//! Shared `BitTorrent` runtime coordination helpers for dispatcher entrypoints. + +pub(super) use self::reporting::{ + BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot, + BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus, +}; +use super::{ + BtPeerInfo, BtPieceAvailabilityUpdate, DhtMessageBody, DhtMessageModel, DhtNodeModel, + DhtResponseModel, DhtTransport, Digest, DownloadStatus, InProcessRpcDispatcher, + PeerWireMetadataMessageType, PeerWireTransportConnector, PeerWireTransportResponse, PieceId, + PieceState, RpcError, TrackerScrapeModel, TrackerTransport, bt_metadata_piece_span, + bt_peer_is_connectable, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes, + bt_runtime_total_length, bt_verified_length, build_dht_announce_peer_request, + build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request, + build_tracker_request, hex_string, merge_bt_dht_nodes, missing_download_error, + parse_dht_compact_nodes, parse_dht_compact_peers, parse_dht_node_spec, parse_gid_text, + peer_wire_bitfield_is_complete, promote_bt_dht_node, push_bt_runtime_coordinator_result, + rpc_bt_info_hash, skipped_bt_runtime_coordinator_step, try_promote_bt_metadata, u32_from_usize, + u64_from_usize, +}; + +/// DHT runtime coordinator operations. +mod dht; +/// Peer-wire runtime coordinator operations. +mod peer_wire; +/// Coordinator report types shared with dispatcher tests and RPC payload shaping. +mod reporting; +/// Runtime snapshot and coordinator orchestration methods. +mod runtime_state; +/// Tracker runtime coordinator operations. +mod tracker; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/dht.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/dht.rs new file mode 100644 index 0000000..9b420a2 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/dht.rs @@ -0,0 +1,327 @@ +use super::{ + DhtMessageBody, DhtMessageModel, DhtNodeModel, DhtResponseModel, DhtTransport, + InProcessRpcDispatcher, RpcError, build_dht_announce_peer_request, build_dht_find_node_request, + build_dht_get_peers_request, build_dht_ping_request, merge_bt_dht_nodes, + missing_download_error, parse_dht_compact_nodes, parse_dht_compact_peers, parse_gid_text, + promote_bt_dht_node, u32_from_usize, +}; + +impl InProcessRpcDispatcher { + /// Applies a DHT get-peers response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the DHT payload cannot be applied. + pub fn apply_dht_get_peers_result( + &mut self, + gid: &str, + node: &DhtNodeModel, + response: &DhtMessageModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + + let get_peers = match &response.body { + DhtMessageBody::Response(DhtResponseModel::GetPeers(model)) => model, + DhtMessageBody::Error(error) => { + return Err(RpcError::unsupported(&format!( + "dht get_peers returned error {}: {}", + error.code, error.message + ))); + } + _ => { + return Err(RpcError::unsupported( + "dht get_peers requires a get_peers response", + )); + } + }; + + let peers = parse_dht_compact_peers(&get_peers.values) + .map_err(|error| RpcError::unsupported(&format!("invalid dht peer values: {error}")))?; + let discovered_nodes = parse_dht_compact_nodes(get_peers.nodes.as_deref()) + .map_err(|error| RpcError::unsupported(&format!("invalid dht nodes: {error}")))?; + + if !peers.is_empty() { + self.engine + .apply_bt_peer_snapshot(gid, peers) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + } + { + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + group.set_dht_token(get_peers.token.clone()); + } + + let peer_count = self + .engine + .registry() + .get(gid) + .and_then(|group| group.bt()) + .map(|bt| u32_from_usize(bt.peers.len())) + .unwrap_or(0); + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + group.set_num_connections(peer_count); + let bt = group + .bt_mut() + .ok_or_else(|| RpcError::unsupported("dht apply requires bt runtime state"))?; + merge_bt_dht_nodes( + &mut bt.dht_nodes, + std::iter::once(format!("{}:{}", node.address, node.port)).chain(discovered_nodes), + ); + Ok(()) + } + + /// Applies a DHT ping response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the DHT payload cannot be applied. + pub fn apply_dht_ping_result( + &mut self, + gid: &str, + node: &DhtNodeModel, + response: &DhtMessageModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + + let ping = match &response.body { + DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model, + DhtMessageBody::Error(error) => { + return Err(RpcError::unsupported(&format!( + "dht ping returned error {}: {}", + error.code, error.message + ))); + } + _ => return Err(RpcError::unsupported("dht ping requires a ping response")), + }; + + if ping.node_id.len() != 20 { + return Err(RpcError::unsupported( + "dht ping response node id must be 20 bytes", + )); + } + + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + let bt = group + .bt_mut() + .ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?; + promote_bt_dht_node(&mut bt.dht_nodes, node); + Ok(()) + } + + /// Applies a DHT find-node response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the DHT payload cannot be applied. + pub fn apply_dht_find_node_result( + &mut self, + gid: &str, + node: &DhtNodeModel, + response: &DhtMessageModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + + let find_node = match &response.body { + DhtMessageBody::Response(DhtResponseModel::FindNode(model)) => model, + DhtMessageBody::Error(error) => { + return Err(RpcError::unsupported(&format!( + "dht find_node returned error {}: {}", + error.code, error.message + ))); + } + _ => { + return Err(RpcError::unsupported( + "dht find_node requires a find_node response", + )); + } + }; + + if find_node.node_id.len() != 20 { + return Err(RpcError::unsupported( + "dht find_node response node id must be 20 bytes", + )); + } + + let discovered_nodes = find_node + .nodes + .iter() + .map(|discovered| { + format!( + "{}.{}.{}.{}:{}", + discovered.address[0], + discovered.address[1], + discovered.address[2], + discovered.address[3], + discovered.port + ) + }) + .collect::>(); + + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + let bt = group + .bt_mut() + .ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?; + promote_bt_dht_node(&mut bt.dht_nodes, node); + merge_bt_dht_nodes(&mut bt.dht_nodes, discovered_nodes); + Ok(()) + } + + /// Applies a DHT announce-peer response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the DHT payload cannot be applied. + pub fn apply_dht_announce_peer_result( + &mut self, + gid: &str, + node: &DhtNodeModel, + response: &DhtMessageModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + + let announce_peer = match &response.body { + DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model, + DhtMessageBody::Error(error) => { + return Err(RpcError::unsupported(&format!( + "dht announce_peer returned error {}: {}", + error.code, error.message + ))); + } + _ => { + return Err(RpcError::unsupported( + "dht announce_peer requires a ping-like response", + )); + } + }; + if announce_peer.node_id.len() != 20 { + return Err(RpcError::unsupported( + "dht announce_peer response node id must be 20 bytes", + )); + } + + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + let bt = group + .bt_mut() + .ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?; + promote_bt_dht_node(&mut bt.dht_nodes, node); + Ok(()) + } + + /// Executes a DHT ping using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails. + pub fn execute_dht_ping( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let (node, request) = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_dht_ping_request(group)? + }; + + let response = transport + .send_message(&node, &request) + .map_err(|error| RpcError::unsupported(&format!("dht ping failed: {error}")))?; + self.apply_dht_ping_result(gid, &node, &response) + } + + /// Executes a DHT find-node query using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails. + pub fn execute_dht_find_node( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let (node, request) = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_dht_find_node_request(group)? + }; + + let response = transport + .send_message(&node, &request) + .map_err(|error| RpcError::unsupported(&format!("dht find_node failed: {error}")))?; + self.apply_dht_find_node_result(gid, &node, &response) + } + + /// Executes a DHT announce-peer query using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails. + pub fn execute_dht_announce_peer( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let (node, request) = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_dht_announce_peer_request(group)? + }; + + let response = transport.send_message(&node, &request).map_err(|error| { + RpcError::unsupported(&format!("dht announce_peer failed: {error}")) + })?; + self.apply_dht_announce_peer_result(gid, &node, &response) + } + + /// Executes a DHT get-peers query using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails. + pub fn execute_dht_get_peers( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let (node, request) = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_dht_get_peers_request(group)? + }; + + let response = transport + .send_message(&node, &request) + .map_err(|error| RpcError::unsupported(&format!("dht get_peers failed: {error}")))?; + self.apply_dht_get_peers_result(gid, &node, &response) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/peer_wire.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/peer_wire.rs new file mode 100644 index 0000000..21f74d2 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/peer_wire.rs @@ -0,0 +1,245 @@ +use super::{ + BtPieceAvailabilityUpdate, DownloadStatus, InProcessRpcDispatcher, PeerWireMetadataMessageType, + PeerWireTransportConnector, PeerWireTransportResponse, PieceId, PieceState, RpcError, + bt_metadata_piece_span, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes, + bt_runtime_total_length, bt_verified_length, missing_download_error, parse_gid_text, + peer_wire_bitfield_is_complete, try_promote_bt_metadata, u32_from_usize, u64_from_usize, +}; +use crate::dispatcher::compat_support::{ + PeerWireExchangePlan, build_peer_wire_exchange_plan, parse_peer_wire_exchange_response, +}; + +impl InProcessRpcDispatcher { + /// Executes a peer-wire exchange using the provided transport connector. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the exchange plan cannot be built, or transport execution fails. + pub fn execute_peer_wire_exchange( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let plan = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_peer_wire_exchange_plan(group)? + }; + let response = transport + .connect_peer_wire(&plan.request) + .map_err(|error| { + RpcError::unsupported(&format!("peer-wire exchange failed: {error}")) + })?; + self.apply_peer_wire_exchange_result(gid, &plan, &response) + } + + /// Applies a peer-wire exchange result to BitTorrent runtime state and piece progress. + fn apply_peer_wire_exchange_result( + &mut self, + gid: &str, + plan: &PeerWireExchangePlan, + response: &PeerWireTransportResponse, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + let parsed = parse_peer_wire_exchange_response( + response, + &plan.info_hash, + plan.metadata_extension_id, + )?; + let should_complete; + { + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + if group.bt().is_none() { + return Err(RpcError::unsupported( + "peer-wire apply requires bt runtime state", + )); + } + let mut runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only); + { + let bt = group.bt_mut().ok_or_else(|| { + RpcError::unsupported("peer-wire apply requires bt runtime state") + })?; + let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| { + RpcError::unsupported( + "peer-wire apply target peer disappeared from runtime state", + ) + })?; + let peer_key = bt_peer_metadata_key(peer); + + if let Some(peer_id) = parsed.peer_id.clone() { + peer.peer_id = Some(peer_id); + } + if let Some(choked) = parsed.peer_choked { + peer.choked = choked; + } + if let Some(interested) = parsed.peer_interested { + peer.interested = interested; + } + if let Some(extension_handshake) = &parsed.extension_handshake { + if let Some(client_name) = &extension_handshake.client_name { + peer.client_name = Some(client_name.clone()); + } + if let Some(extension_message_id) = extension_handshake.ut_metadata_id() { + bt.metadata_extension_ids + .insert(peer_key.clone(), extension_message_id); + } + if let Some(metadata_size) = extension_handshake.metadata_size { + bt.metadata_size = Some(metadata_size); + } + } + + let mut known_metadata_size = bt.metadata_size; + for message in &parsed.metadata_messages { + match message.message_type { + PeerWireMetadataMessageType::Request + | PeerWireMetadataMessageType::Reject => {} + PeerWireMetadataMessageType::Data => { + let total_size = message.total_size.ok_or_else(|| { + RpcError::unsupported( + "ut_metadata data payload must include total_size", + ) + })?; + if let Some(existing_size) = known_metadata_size { + if existing_size != total_size { + return Err(RpcError::unsupported(&format!( + "conflicting magnet metadata size: expected {existing_size}, got {total_size}", + ))); + } + } else { + bt.metadata_size = Some(total_size); + known_metadata_size = Some(total_size); + } + let expected_len = bt_metadata_piece_span(total_size, message.piece); + if expected_len == 0 { + return Err(RpcError::unsupported(&format!( + "ut_metadata piece {} exceeded metadata size {total_size}", + message.piece + ))); + } + if message.payload.len() > expected_len { + return Err(RpcError::unsupported(&format!( + "ut_metadata piece {} payload too large: expected at most {expected_len} bytes, got {}", + message.piece, + message.payload.len() + ))); + } + bt.metadata_piece_payloads + .insert(message.piece, message.payload.clone()); + } + } + } + } + + if runtime_metadata_only { + let _ = try_promote_bt_metadata(group)?; + runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only); + } + if let Some(request) = &plan.block_request + && !runtime_metadata_only + { + let requested_piece = PieceId(request.piece_index); + if group.piece_state(requested_piece) != Some(PieceState::Verified) { + group.set_piece_state(requested_piece, PieceState::Downloading); + } + } + + let mut downloaded_delta = 0_u64; + if !runtime_metadata_only { + let piece_length = group.piece_length().max(1); + let total_length = bt_runtime_total_length(group); + for piece in &parsed.pieces { + let piece_id = PieceId(piece.piece_index); + if piece.block.is_empty() { + continue; + } + downloaded_delta = + downloaded_delta.saturating_add(u64_from_usize(piece.block.len())); + let expected_len = + bt_piece_span_bytes(piece_id, piece_length, total_length).max(1); + if piece.block_offset == 0 && u64_from_usize(piece.block.len()) >= expected_len + { + group.set_piece_state(piece_id, PieceState::Verified); + } else if group.piece_state(piece_id) != Some(PieceState::Verified) { + group.set_piece_state(piece_id, PieceState::Downloading); + } + } + let num_pieces = bt_piece_count(total_length, piece_length); + let completed_length = bt_verified_length(group, piece_length, total_length); + if completed_length > group.completed_length() { + group.set_completed_length(completed_length); + } + for piece in &parsed.available_pieces { + if *piece >= u32_from_usize(num_pieces) { + continue; + } + group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate { + piece_id: PieceId(*piece), + peers_with_piece: 1, + }); + } + should_complete = total_length > 0 && completed_length >= total_length; + } else { + should_complete = false; + } + group.set_download_speed(downloaded_delta); + if !matches!( + group.status(), + DownloadStatus::Complete | DownloadStatus::Removed + ) { + group.set_status(DownloadStatus::Active); + } + + let peer_runtime_piece_length = group.piece_length().max(1); + let peer_runtime_total_length = bt_runtime_total_length(group); + let peer_runtime_piece_count = + bt_piece_count(peer_runtime_total_length, peer_runtime_piece_length); + let peer_stats; + { + let bt = group.bt_mut().ok_or_else(|| { + RpcError::unsupported("peer-wire apply requires bt runtime state") + })?; + let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| { + RpcError::unsupported( + "peer-wire apply target peer disappeared from runtime state", + ) + })?; + if let Some(peer_id) = parsed.peer_id.clone() { + peer.peer_id = Some(peer_id); + } + if let Some(choked) = parsed.peer_choked { + peer.choked = choked; + } + if let Some(interested) = parsed.peer_interested { + peer.interested = interested; + } + peer.download_speed = downloaded_delta; + peer.upload_speed = u64_from_usize(plan.request.payload.len()); + if let Some(bitfield) = &parsed.bitfield_pieces { + peer.seeder = + peer_wire_bitfield_is_complete(bitfield, peer_runtime_piece_count); + } else if parsed.available_pieces.len() >= peer_runtime_piece_count + && peer_runtime_piece_count > 0 + { + peer.seeder = (0..u32_from_usize(peer_runtime_piece_count)) + .all(|piece| parsed.available_pieces.contains(&piece)); + } + let updated_peer = peer.clone(); + peer_stats = group.apply_bt_peer_update(updated_peer); + } + group.set_num_connections(u32_from_usize(peer_stats.peer_count)); + } + if should_complete { + self.engine + .complete(gid) + .map_err(|_| missing_download_error(gid))?; + } + Ok(()) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/reporting.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/reporting.rs new file mode 100644 index 0000000..9017886 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/reporting.rs @@ -0,0 +1,92 @@ +use super::DownloadStatus; + +/// One coordinator-visible BitTorrent action that the dispatcher can execute in a loop. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BtRuntimeCoordinatorAction { + /// Advance share/seeding timers using the supplied wall clock. + AdvanceClock, + /// Refresh peers and tracker metadata from the primary tracker. + TrackerAnnounce, + /// Query DHT for peers against the current info hash. + DhtGetPeers, + /// Expand the DHT node frontier when peers are still unavailable. + DhtFindNode, + /// Announce the local presence back into DHT once a token is cached. + DhtAnnouncePeer, + /// Perform one peer-wire request/response exchange against the best current peer. + PeerWireExchange, +} + +/// Outcome classification for one coordinator-visible BitTorrent action. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BtRuntimeCoordinatorStepStatus { + /// The dispatcher executed the action successfully. + Executed, + /// The dispatcher intentionally skipped the action because a prerequisite was absent. + Skipped, + /// The dispatcher attempted the action and it failed. + Failed, +} + +/// Detailed result for one BitTorrent coordinator action. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BtRuntimeCoordinatorStepReport { + /// Action that was evaluated. + pub action: BtRuntimeCoordinatorAction, + /// Final status for the action in this loop iteration. + pub status: BtRuntimeCoordinatorStepStatus, + /// Optional human-readable detail for skips and failures. + pub detail: Option, +} + +/// Snapshot of dispatcher-visible BitTorrent runtime state for loop orchestration. +#[derive(Clone, Debug, Eq, PartialEq)] +#[expect( + clippy::struct_excessive_bools, + reason = "aria2-compatible BT status snapshots intentionally surface several independent boolean facets" +)] +pub struct BtRuntimeCoordinatorSnapshot { + /// Download GID in aria2 hex form. + pub gid: String, + /// Current aria2/core download state. + pub status: DownloadStatus, + /// Whether the BT runtime currently considers the local side seeding. + pub seeding: bool, + /// Aggregate number of bytes marked complete in the request group. + pub completed_length: u64, + /// Aggregate total length known to the runtime. + pub total_length: u64, + /// Live connection count currently exposed through aria2 status surfaces. + pub connections: u32, + /// Number of configured tracker entries in BT runtime state. + pub tracker_count: usize, + /// Total DHT node entries currently cached, including malformed ones. + pub dht_node_count: usize, + /// Number of DHT node entries that can actually be converted into transport targets. + pub addressable_dht_node_count: usize, + /// Total peer rows currently cached in BT runtime state. + pub peer_count: usize, + /// Number of peers that are currently usable for peer-wire transport. + pub connectable_peer_count: usize, + /// Whether a DHT announce token is already cached from a prior get_peers response. + pub has_dht_token: bool, + /// Whether the BT runtime is still metadata-only. + pub metadata_only: bool, + /// Whether this looks like a magnet-backed partial path that still lacks metadata exchange support. + pub metadata_exchange_pending: bool, + /// Number of locally requestable pieces remaining under the current piece state. + pub requestable_piece_count: usize, + /// Dispatcher-level action suggestions derived from the current snapshot. + pub recommended_actions: Vec, +} + +/// Full report for one dispatcher-driven BitTorrent loop iteration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BtRuntimeCoordinatorReport { + /// Snapshot captured before any coordinator actions were attempted. + pub initial_snapshot: BtRuntimeCoordinatorSnapshot, + /// Snapshot captured after the last attempted coordinator action. + pub final_snapshot: BtRuntimeCoordinatorSnapshot, + /// Ordered per-action results for this iteration. + pub steps: Vec, +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/runtime_state.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/runtime_state.rs new file mode 100644 index 0000000..13fecac --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/runtime_state.rs @@ -0,0 +1,270 @@ +use super::{ + BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot, + DhtTransport, Digest, InProcessRpcDispatcher, PeerWireTransportConnector, RpcError, + TrackerTransport, bt_peer_is_connectable, bt_runtime_total_length, missing_download_error, + parse_dht_node_spec, parse_gid_text, push_bt_runtime_coordinator_result, + skipped_bt_runtime_coordinator_step, +}; + +impl InProcessRpcDispatcher { + /// Applies a BitTorrent runtime tick update to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the engine rejects the runtime update. + pub fn apply_bt_runtime_tick( + &mut self, + gid: &str, + 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, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + self.engine + .apply_bt_runtime_tick( + gid, + downloaded_delta, + uploaded_delta, + download_speed, + upload_speed, + share_time_delta_secs, + seeding_time_delta_secs, + seeding, + num_connections, + ) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + Ok(()) + } + + /// Advances the BitTorrent runtime clock for a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the engine rejects the clock update. + pub fn tick_bt_runtime_clock( + &mut self, + gid: &str, + now_unix_secs: u64, + seeding: bool, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + self.engine + .tick_bt_runtime_clock(gid, now_unix_secs, seeding) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + Ok(()) + } + + /// Sets whether a BitTorrent download is currently seeding. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the engine rejects the seeding update. + pub fn set_bt_seeding_state( + &mut self, + gid: &str, + seeding: bool, + at_unix_secs: Option, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + self.engine + .set_bt_seeding_state(gid, seeding, at_unix_secs) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + Ok(()) + } + + /// Captures a coordinator-friendly BitTorrent runtime snapshot for one download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state. + pub fn bt_runtime_coordinator_snapshot( + &self, + gid: &str, + ) -> Result { + let gid = parse_gid_text(gid)?; + let group = self + .engine + .registry() + .get(gid) + .ok_or_else(|| missing_download_error(gid))?; + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("bt coordinator requires bt runtime state"))?; + + let has_dht_token = group.dht_token().is_some(); + let addressable_dht_node_count = bt + .dht_nodes + .iter() + .filter(|raw| parse_dht_node_spec(raw).is_ok()) + .count(); + let connectable_peer_count = bt + .peers + .iter() + .filter(|peer| bt_peer_is_connectable(peer)) + .count(); + let (pending, queued, _, _, missing, _) = group.piece_state_counts(); + let requestable_piece_count = pending + queued + missing; + let metadata_exchange_pending = bt.metadata_only && bt.magnet_uri.is_some(); + + let mut recommended_actions = Vec::new(); + if !bt.trackers.is_empty() { + recommended_actions.push(BtRuntimeCoordinatorAction::TrackerAnnounce); + } + if addressable_dht_node_count > 0 { + recommended_actions.push(BtRuntimeCoordinatorAction::DhtGetPeers); + if connectable_peer_count == 0 { + recommended_actions.push(BtRuntimeCoordinatorAction::DhtFindNode); + } + if has_dht_token { + recommended_actions.push(BtRuntimeCoordinatorAction::DhtAnnouncePeer); + } + } + if connectable_peer_count > 0 { + recommended_actions.push(BtRuntimeCoordinatorAction::PeerWireExchange); + } + + Ok(BtRuntimeCoordinatorSnapshot { + gid: format!("{:016x}", gid.as_u64()), + status: *group.status(), + seeding: group.bt_is_seeding(), + completed_length: group.completed_length(), + total_length: bt_runtime_total_length(group), + connections: group.num_connections(), + tracker_count: bt.trackers.len(), + dht_node_count: bt.dht_nodes.len(), + addressable_dht_node_count, + peer_count: bt.peers.len(), + connectable_peer_count, + has_dht_token, + metadata_only: bt.metadata_only, + metadata_exchange_pending, + requestable_piece_count, + recommended_actions, + }) + } + + /// Drives one coordinator-friendly BitTorrent loop iteration using any available transports. + /// + /// This helper intentionally stays transport-neutral: it consumes already-built tracker, DHT, + /// and peer-wire transports when provided, and it reports the remaining metadata-only magnet gap + /// instead of pretending to implement BEP9/ut_metadata locally. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state. + pub fn drive_bt_runtime_once( + &mut self, + gid: &str, + tracker_transport: Option<&dyn TrackerTransport>, + dht_transport: Option<&dyn DhtTransport>, + peer_wire_transport: Option<&dyn PeerWireTransportConnector>, + now_unix_secs: Option, + ) -> Result { + let initial_snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + let mut steps = Vec::new(); + + if let Some(now_unix_secs) = now_unix_secs { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::AdvanceClock, + self.tick_bt_runtime_clock(gid, now_unix_secs, initial_snapshot.seeding), + ); + } + + let mut snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + if snapshot.tracker_count > 0 { + if let Some(transport) = tracker_transport { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::TrackerAnnounce, + self.execute_tracker_announce(gid, transport), + ); + snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + } else { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::TrackerAnnounce, + "tracker transport unavailable", + )); + } + } + + if snapshot.addressable_dht_node_count > 0 { + if let Some(transport) = dht_transport { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::DhtGetPeers, + self.execute_dht_get_peers(gid, transport), + ); + snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + } else { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::DhtGetPeers, + "dht transport unavailable", + )); + } + } else if snapshot.dht_node_count > 0 { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::DhtGetPeers, + "no usable dht nodes in runtime state", + )); + } + + if snapshot.addressable_dht_node_count > 0 && snapshot.connectable_peer_count == 0 { + if let Some(transport) = dht_transport { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::DhtFindNode, + self.execute_dht_find_node(gid, transport), + ); + snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + } else { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::DhtFindNode, + "dht transport unavailable", + )); + } + } + + if snapshot.addressable_dht_node_count > 0 && snapshot.has_dht_token { + if let Some(transport) = dht_transport { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::DhtAnnouncePeer, + self.execute_dht_announce_peer(gid, transport), + ); + snapshot = self.bt_runtime_coordinator_snapshot(gid)?; + } else { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::DhtAnnouncePeer, + "dht transport unavailable", + )); + } + } + + if snapshot.connectable_peer_count > 0 { + if let Some(transport) = peer_wire_transport { + push_bt_runtime_coordinator_result( + &mut steps, + BtRuntimeCoordinatorAction::PeerWireExchange, + self.execute_peer_wire_exchange(gid, transport), + ); + } else { + steps.push(skipped_bt_runtime_coordinator_step( + BtRuntimeCoordinatorAction::PeerWireExchange, + "peer-wire transport unavailable", + )); + } + } + + Ok(BtRuntimeCoordinatorReport { + initial_snapshot, + final_snapshot: self.bt_runtime_coordinator_snapshot(gid)?, + steps, + }) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/tracker.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/tracker.rs new file mode 100644 index 0000000..0c60f07 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/tracker.rs @@ -0,0 +1,180 @@ +use super::{ + BtPeerInfo, InProcessRpcDispatcher, RpcError, TrackerScrapeModel, TrackerTransport, + build_tracker_request, hex_string, missing_download_error, parse_gid_text, rpc_bt_info_hash, +}; + +impl InProcessRpcDispatcher { + /// Applies a tracker announce response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the tracker payload cannot be applied. + pub fn apply_tracker_announce_result( + &mut self, + gid: &str, + response: &aria2_rust_pro_protocol::tracker::TrackerResponseModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + let (tracker_url, peers) = { + let group = self + .engine + .registry() + .get(gid) + .ok_or_else(|| missing_download_error(gid))?; + let tracker_url = group + .bt() + .and_then(|bt| bt.trackers.first().map(|tracker| tracker.url.clone())) + .ok_or_else(|| { + RpcError::unsupported("tracker announce apply requires at least one tracker") + })?; + let peers = response + .peers + .peers + .iter() + .cloned() + .map(|peer| BtPeerInfo { + peer_id: peer.peer_id.map(|id| hex_string(&id).to_ascii_lowercase()), + ip: peer.ip, + port: peer.port, + client_name: peer.client_name, + interested: peer.interested, + choked: peer.choked, + download_speed: 0, + upload_speed: 0, + seeder: false, + }) + .collect::>(); + (tracker_url, peers) + }; + self.engine + .apply_bt_peer_snapshot(gid, peers) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + self.engine + .apply_bt_tracker_snapshot( + gid, + &tracker_url, + response.peers.tracker_id.clone(), + None, + None, + ) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + if let Some(scrape) = &response.scrape { + self.apply_tracker_scrape_result(&gid.to_string(), Some(&tracker_url), scrape)?; + } + Ok(()) + } + + /// Applies a tracker scrape response to a tracked download. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the scrape payload cannot be applied. + pub fn apply_tracker_scrape_result( + &mut self, + gid: &str, + tracker_url: Option<&str>, + scrape: &TrackerScrapeModel, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + let (resolved_tracker_url, resolved_complete, resolved_incomplete) = { + let group = self + .engine + .registry() + .get(gid) + .ok_or_else(|| missing_download_error(gid))?; + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("tracker scrape requires bt runtime state"))?; + let tracker_url = tracker_url + .map(ToOwned::to_owned) + .or_else(|| bt.trackers.first().map(|tracker| tracker.url.clone())) + .ok_or_else(|| { + RpcError::unsupported("tracker scrape apply requires at least one tracker") + })?; + let info_hash = if !bt.info_hash.is_empty() { + bt.info_hash.to_ascii_lowercase() + } else { + rpc_bt_info_hash(group.uri()) + .unwrap_or_default() + .to_ascii_lowercase() + }; + let file_match = scrape + .files + .iter() + .find(|file| file.info_hash.eq_ignore_ascii_case(&info_hash)); + let complete = file_match + .and_then(|file| file.complete) + .or(scrape.complete); + let incomplete = file_match + .and_then(|file| file.incomplete) + .or(scrape.incomplete); + (tracker_url, complete, incomplete) + }; + + self.engine + .apply_bt_tracker_snapshot( + gid, + &resolved_tracker_url, + None, + resolved_complete, + resolved_incomplete, + ) + .map_err(|error| RpcError::unsupported(&error.to_string()))?; + Ok(()) + } + + /// Executes a tracker scrape using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails. + pub fn execute_tracker_scrape( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let request = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_tracker_request(group)? + }; + let scrape = transport + .scrape(&request.announce_url) + .map_err(|error| RpcError::unsupported(&format!("tracker scrape failed: {error}")))?; + self.apply_tracker_scrape_result(gid, Some(&request.announce_url), &scrape) + } + + /// Executes a tracker announce using the provided transport. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails. + pub fn execute_tracker_announce( + &mut self, + gid: &str, + transport: &T, + ) -> Result<(), RpcError> { + let download_id = parse_gid_text(gid)?; + let request = { + let group = self + .engine + .registry() + .get(download_id) + .ok_or_else(|| missing_download_error(download_id))?; + build_tracker_request(group)? + }; + let mut response = transport + .announce(&request) + .map_err(|error| RpcError::unsupported(&format!("tracker announce failed: {error}")))?; + if response.scrape.is_none() + && let Ok(scrape) = transport.scrape(&request.announce_url) + { + response.scrape = Some(scrape); + } + self.apply_tracker_announce_result(gid, &response) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support.rs new file mode 100644 index 0000000..2b5b71d --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support.rs @@ -0,0 +1,28 @@ +//! Shared compatibility helpers that bridge dispatcher state into aria2-style payloads. + +pub(super) use self::{ + bt_runtime::*, dht::*, peer_wire::*, rpc_surface::*, selection::*, tracker::*, +}; +use super::{ + AtomicU64, BTreeMap, BTreeSet, BtFileInfo, BtPeerInfo, BtRuntimeCoordinatorAction, + BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus, BtRuntimeState, BtTrackerInfo, + DhtMessageModel, DhtNodeModel, Digest, DownloadId, MagnetBootstrapModel, MagnetUriModel, + Ordering, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, + PeerWireMessageKind, PeerWireMetadataMessageModel, PeerWirePieceBlockModel, + PeerWireTransportRequest, PeerWireTransportResponse, PieceId, PieceMap, PieceState, + RequestGroup, RpcError, Sha1, SystemTime, TorrentMessageModel, TorrentMetadataModel, + TrackerRequestModel, TransportEndpoint, TransportScheme, UNIX_EPOCH, parse_torrent_metadata, +}; + +/// BitTorrent runtime state construction and accounting helpers. +mod bt_runtime; +/// DHT request construction and compact payload parsing helpers. +mod dht; +/// Peer-wire request, response, and accounting helpers. +mod peer_wire; +/// RPC-facing compatibility value formatting helpers. +mod rpc_surface; +/// BitTorrent file selection option helpers. +mod selection; +/// Tracker announce request construction helpers. +mod tracker; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/bt_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/bt_runtime.rs new file mode 100644 index 0000000..ea266a6 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/bt_runtime.rs @@ -0,0 +1,362 @@ +use super::{ + BTreeMap, BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DhtNodeModel, Digest, + MagnetBootstrapModel, MagnetUriModel, PieceId, PieceMap, PieceState, RequestGroup, RpcError, + TorrentMetadataModel, hex_string, merge_bt_dht_nodes, parse_torrent_metadata, +}; + +/// Extracts an uppercase BitTorrent info hash from a magnet URI. +pub(in crate::dispatcher) fn rpc_bt_info_hash(uri: &str) -> Option { + let lower = uri.to_ascii_lowercase(); + let marker = "xt=urn:btih:"; + let start = lower.find(marker)?; + let raw = &uri[start + marker.len()..]; + let token = raw.split('&').next().unwrap_or(raw); + let normalized: String = token + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .map(|ch| ch.to_ascii_uppercase()) + .collect(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +/// Converts parsed torrent metadata into the core BitTorrent runtime model. +pub(in crate::dispatcher) fn build_bt_runtime_state( + metadata: &TorrentMetadataModel, +) -> BtRuntimeState { + let trackers: Vec = metadata + .trackers + .iter() + .map(|tracker| BtTrackerInfo { + url: tracker.url.clone(), + tier: tracker.tier, + id: tracker.id.clone(), + seeders: tracker.seeders, + leechers: tracker.leechers, + }) + .collect(); + let files = metadata + .info + .files + .iter() + .map(|file| BtFileInfo { + path: file.path.clone(), + length: file.length, + piece_offset: file.piece_offset, + selected: file.selected, + }) + .collect(); + let peers = metadata + .peers + .iter() + .map(|peer| BtPeerInfo { + peer_id: peer.peer_id.map(|peer_id| hex_string(&peer_id)), + ip: peer.ip.clone(), + port: peer.port, + client_name: peer.client_name.clone(), + interested: peer.interested, + choked: peer.choked, + download_speed: 0, + upload_speed: 0, + seeder: false, + }) + .collect(); + let info_hash = metadata + .info + .hash + .as_ref() + .map(|hash| hash.info_hash_hex.to_ascii_uppercase()) + .unwrap_or_default(); + let magnet_uri = (!info_hash.is_empty()).then(|| { + MagnetUriModel { + info_hash: info_hash.clone(), + display_name: Some(metadata.info.name.clone()), + trackers: trackers.iter().map(|tracker| tracker.url.clone()).collect(), + web_seeds: Vec::new(), + exact_topic: None, + } + .to_uri() + }); + + BtRuntimeState { + info_hash, + name: Some(metadata.info.name.clone()), + magnet_uri, + metadata_only: false, + metadata_size: None, + metadata_extension_ids: BTreeMap::new(), + metadata_piece_payloads: BTreeMap::new(), + creation_date: metadata.creation_date.clone(), + comment: metadata.comment.clone(), + dht_nodes: initial_bt_dht_nodes(&metadata.dht_nodes), + files, + trackers, + peers, + } +} + +/// Seeds BitTorrent runtime state from a magnet URI before metadata arrives. +pub(in crate::dispatcher) fn build_bt_runtime_state_from_magnet( + uri: &str, + magnet: &MagnetBootstrapModel, +) -> BtRuntimeState { + let magnet_uri = Some(uri.to_owned()); + let trackers = magnet + .trackers + .iter() + .map(|tracker| BtTrackerInfo { + url: tracker.url.clone(), + tier: tracker.tier, + id: tracker.id.clone(), + seeders: tracker.seeders, + leechers: tracker.leechers, + }) + .collect(); + let peer_hints = magnet + .peer_hints + .iter() + .map(|peer| BtPeerInfo { + peer_id: peer + .peer_id + .map(|peer_id: [u8; 20]| hex_string(&peer_id[..])), + ip: peer.ip.clone(), + port: peer.port, + client_name: peer.client_name.clone(), + interested: peer.interested, + choked: peer.choked, + download_speed: 0, + upload_speed: 0, + seeder: false, + }) + .collect(); + let hinted_dht_nodes = magnet + .peer_hint_nodes + .iter() + .map(DhtNodeModel::to_spec) + .collect::>(); + BtRuntimeState { + info_hash: magnet.info_hash_hex.to_ascii_uppercase(), + name: magnet.uri.display_name.clone(), + magnet_uri, + metadata_only: true, + metadata_size: None, + metadata_extension_ids: BTreeMap::new(), + metadata_piece_payloads: BTreeMap::new(), + creation_date: None, + comment: None, + dht_nodes: initial_bt_dht_nodes(&hinted_dht_nodes), + files: Vec::new(), + trackers, + peers: peer_hints, + } +} + +/// Returns the built-in fallback DHT router list. +pub(in crate::dispatcher) fn default_bt_dht_nodes() -> Vec { + vec![ + "router.bittorrent.com:6881".to_owned(), + "dht.transmissionbt.com:6881".to_owned(), + "router.utorrent.com:6881".to_owned(), + ] +} + +/// Chooses explicit DHT nodes when present and otherwise falls back to router defaults. +pub(in crate::dispatcher) fn initial_bt_dht_nodes(explicit_nodes: &[String]) -> Vec { + if explicit_nodes.is_empty() { + default_bt_dht_nodes() + } else { + explicit_nodes.to_vec() + } +} + +/// Returns the BEP 9 metadata piece size used by aria2-compatible peers. +pub(in crate::dispatcher) const BT_METADATA_PIECE_LENGTH: u64 = 16 * 1024; + +/// Returns the number of metadata pieces needed for one BEP 9 payload size. +pub(in crate::dispatcher) fn bt_metadata_piece_count(metadata_size: u32) -> u32 { + if metadata_size == 0 { + return 0; + } + metadata_size.div_ceil(BT_METADATA_PIECE_LENGTH as u32) +} + +/// Returns the byte length of one metadata piece within the BEP 9 payload. +pub(in crate::dispatcher) fn bt_metadata_piece_span(metadata_size: u32, piece_index: u32) -> usize { + let piece_start = u64::from(piece_index).saturating_mul(BT_METADATA_PIECE_LENGTH); + let metadata_size = u64::from(metadata_size); + if piece_start >= metadata_size { + return 0; + } + let remaining = metadata_size.saturating_sub(piece_start); + usize::try_from(remaining.min(BT_METADATA_PIECE_LENGTH)).unwrap_or(usize::MAX) +} + +/// Returns a stable runtime key for one BT peer endpoint. +pub(in crate::dispatcher) fn bt_peer_metadata_key(peer: &BtPeerInfo) -> String { + format!("{}:{}", peer.ip, peer.port) +} + +/// Merges BT tracker snapshots by URL while preserving any existing runtime rows. +pub(in crate::dispatcher) fn merge_bt_trackers( + existing: &mut Vec, + incoming: Vec, +) { + for tracker in incoming { + if existing.iter().any(|current| current.url == tracker.url) { + continue; + } + existing.push(tracker); + } +} + +/// Merges BT peer snapshots while preserving existing runtime rows. +pub(in crate::dispatcher) fn merge_bt_peers( + existing: &mut Vec, + incoming: Vec, +) { + for peer in incoming { + if let Some(current) = existing.iter_mut().find(|candidate| { + candidate.peer_id.as_deref() == peer.peer_id.as_deref() + || (candidate.ip == peer.ip && candidate.port == peer.port) + }) { + *current = peer; + } else { + existing.push(peer); + } + } +} + +/// Promotes a metadata-only magnet runtime into a full torrent-backed BT session when ready. +pub(in crate::dispatcher) fn try_promote_bt_metadata( + group: &mut RequestGroup, +) -> Result { + let Some(bt) = group.bt().cloned() else { + return Err(RpcError::unsupported( + "metadata promotion requires bt runtime state", + )); + }; + if !bt.metadata_only { + return Ok(false); + } + let Some(metadata_size) = bt.metadata_size.filter(|size| *size > 0) else { + return Ok(false); + }; + let piece_count = bt_metadata_piece_count(metadata_size); + if piece_count == 0 { + return Ok(false); + } + + let mut metadata_bytes = + Vec::with_capacity(usize::try_from(metadata_size).unwrap_or(usize::MAX)); + for piece in 0..piece_count { + let Some(payload) = bt.metadata_piece_payloads.get(&piece) else { + return Ok(false); + }; + let expected_len = bt_metadata_piece_span(metadata_size, piece); + if payload.len() < expected_len { + return Ok(false); + } + metadata_bytes.extend_from_slice(&payload[..expected_len]); + } + metadata_bytes.truncate(usize::try_from(metadata_size).unwrap_or(usize::MAX)); + + let metadata = parse_torrent_metadata(&metadata_bytes).map_err(|error| { + RpcError::unsupported(&format!("invalid magnet metadata payload: {error}")) + })?; + let parsed_info_hash = metadata + .info + .hash + .as_ref() + .map(|hash| hash.info_hash_hex.to_ascii_uppercase()) + .unwrap_or_default(); + if parsed_info_hash.is_empty() { + return Err(RpcError::unsupported( + "promoted torrent metadata did not expose an info hash", + )); + } + if !bt.info_hash.is_empty() && !parsed_info_hash.eq_ignore_ascii_case(&bt.info_hash) { + return Err(RpcError::unsupported(&format!( + "magnet metadata info hash mismatch: expected {}, got {parsed_info_hash}", + bt.info_hash + ))); + } + + let mut promoted = build_bt_runtime_state(&metadata); + promoted.magnet_uri = bt.magnet_uri.clone(); + promoted.metadata_size = Some(metadata_size); + promoted.metadata_extension_ids = bt.metadata_extension_ids.clone(); + promoted.metadata_piece_payloads = bt.metadata_piece_payloads.clone(); + promoted.dht_nodes = bt.dht_nodes.clone(); + merge_bt_dht_nodes(&mut promoted.dht_nodes, metadata.dht_nodes.iter().cloned()); + merge_bt_trackers(&mut promoted.trackers, bt.trackers.clone()); + let metadata_peers = std::mem::take(&mut promoted.peers); + promoted.peers = bt.peers.clone(); + merge_bt_peers(&mut promoted.peers, metadata_peers); + + group.set_bt(promoted); + group.set_total_length(metadata.total_length()); + group.set_piece_length(metadata.info.piece_length.max(1)); + group.set_completed_length(0); + group.clear_piece_availability(); + group.clear_segment_assignments(); + *group.piece_map_mut() = PieceMap::new(); + for piece in &metadata.pieces { + group.set_piece_state(PieceId(piece.index), PieceState::Pending); + } + Ok(true) +} + +/// Computes the effective total length visible to BitTorrent runtime reporting. +pub(in crate::dispatcher) fn bt_runtime_total_length(group: &RequestGroup) -> u64 { + group.total_length().max( + group + .bt() + .map(BtRuntimeState::selected_or_all_total_length) + .unwrap_or_default(), + ) +} + +/// Computes the number of pieces required to cover a torrent payload. +pub(in crate::dispatcher) fn bt_piece_count(total_length: u64, piece_length: u64) -> usize { + if total_length == 0 { + 0 + } else { + total_length.div_ceil(piece_length.max(1)) as usize + } +} + +/// Computes the byte span represented by a single BitTorrent piece. +pub(in crate::dispatcher) fn bt_piece_span_bytes( + piece: PieceId, + piece_length: u64, + total_length: u64, +) -> u64 { + let piece_length = piece_length.max(1); + if total_length == 0 { + return piece_length; + } + let start = u64::from(piece.0).saturating_mul(piece_length); + total_length.saturating_sub(start).min(piece_length) +} + +/// Sums the verified length implied by the request group's piece map. +pub(in crate::dispatcher) fn bt_verified_length( + group: &RequestGroup, + piece_length: u64, + total_length: u64, +) -> u64 { + group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .map(|(piece, _)| bt_piece_span_bytes(*piece, piece_length, total_length)) + .sum() +} + +/// Returns whether a BT peer row is usable for an outbound peer-wire exchange. +pub(in crate::dispatcher) fn bt_peer_is_connectable(peer: &BtPeerInfo) -> bool { + !peer.ip.trim().is_empty() && peer.port != 0 +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/dht.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/dht.rs new file mode 100644 index 0000000..39a8256 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/dht.rs @@ -0,0 +1,240 @@ +use super::{ + BtPeerInfo, BtRuntimeState, DhtMessageModel, DhtNodeModel, Digest, DownloadId, RequestGroup, + RpcError, rpc_bt_info_hash, +}; + +/// Builds the outbound DHT `get_peers` message and selected target node. +pub(in crate::dispatcher) fn build_dht_get_peers_request( + group: &RequestGroup, +) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("dht get_peers requires bt runtime state"))?; + let node = pick_bt_dht_node(bt.dht_nodes())?; + let info_hash = resolve_bt_info_hash(group, bt)?; + Ok(( + node, + DhtMessageModel::get_peers_query( + b"gp".to_vec(), + rpc_bt_local_node_id(group.gid()), + info_hash, + ), + )) +} + +/// Builds the outbound DHT `ping` message and selected target node. +pub(in crate::dispatcher) fn build_dht_ping_request( + group: &RequestGroup, +) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?; + let node = pick_bt_dht_node(bt.dht_nodes())?; + Ok(( + node, + DhtMessageModel::ping_query(b"pi".to_vec(), rpc_bt_local_node_id(group.gid())), + )) +} + +/// Builds the outbound DHT `find_node` message and selected target node. +pub(in crate::dispatcher) fn build_dht_find_node_request( + group: &RequestGroup, +) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?; + let node = pick_bt_dht_node(bt.dht_nodes())?; + let target = resolve_bt_info_hash(group, bt)?; + Ok(( + node, + DhtMessageModel::find_node_query(b"fn".to_vec(), rpc_bt_local_node_id(group.gid()), target), + )) +} + +/// Builds the outbound DHT `announce_peer` message and selected target node. +pub(in crate::dispatcher) fn build_dht_announce_peer_request( + group: &RequestGroup, +) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?; + let node = pick_bt_dht_node(bt.dht_nodes())?; + let token = group + .dht_token() + .map(|token| token.to_vec()) + .ok_or_else(|| { + RpcError::unsupported("dht announce_peer requires token from prior get_peers") + })?; + let info_hash = resolve_bt_info_hash(group, bt)?; + Ok(( + node, + DhtMessageModel::announce_peer_query( + b"ap".to_vec(), + rpc_bt_local_node_id(group.gid()), + info_hash, + 6881, + token, + false, + ), + )) +} + +/// Chooses the first parseable DHT node entry from runtime state. +pub(in crate::dispatcher) fn pick_bt_dht_node(nodes: &[String]) -> Result { + if nodes.is_empty() { + return Err(RpcError::unsupported( + "dht get_peers requires at least one dht node", + )); + } + let mut last_error = None; + for node in nodes { + match parse_dht_node_spec(node) { + Ok(parsed) => return Ok(parsed), + Err(error) => last_error = Some(error.message), + } + } + Err(RpcError::unsupported(&format!( + "dht get_peers found no valid dht nodes in runtime state{}", + last_error + .map(|message| format!(": {message}")) + .unwrap_or_default() + ))) +} + +/// Resolves the BitTorrent info hash bytes required by DHT and peer-wire requests. +pub(in crate::dispatcher) fn resolve_bt_info_hash( + group: &RequestGroup, + bt: &BtRuntimeState, +) -> Result, RpcError> { + let info_hash = if !bt.info_hash.is_empty() { + bt.info_hash.clone() + } else { + rpc_bt_info_hash(group.uri()).unwrap_or_default() + }; + decode_hex_string_exact(&info_hash, 20, "dht get_peers info hash") + .map_err(|error| RpcError::unsupported(&error)) +} + +/// Parses a `host:port` DHT node spec into a transport model. +pub(in crate::dispatcher) fn parse_dht_node_spec(raw: &str) -> Result { + let (address, port_raw) = raw + .rsplit_once(':') + .ok_or_else(|| RpcError::unsupported("dht node entry must use host:port format"))?; + let port = port_raw + .parse::() + .map_err(|_| RpcError::unsupported("dht node port must be a valid u16"))?; + Ok(DhtNodeModel { + node_id: String::new(), + address: address.to_owned(), + port, + }) +} + +/// Derives a deterministic local DHT node ID from a download GID. +pub(in crate::dispatcher) fn rpc_bt_local_node_id(gid: DownloadId) -> Vec { + let gid_hex = format!("{:040x}", gid.as_u64()); + decode_hex_string_exact(&gid_hex, 20, "local dht node id").unwrap_or_else(|_| vec![0_u8; 20]) +} + +/// Decodes a fixed-width hexadecimal string into raw bytes. +pub(in crate::dispatcher) fn decode_hex_string_exact( + raw: &str, + expected_len: usize, + label: &str, +) -> Result, String> { + if raw.len() != expected_len * 2 { + return Err(format!( + "{label} must be {} hex characters", + expected_len * 2 + )); + } + let mut bytes = Vec::with_capacity(expected_len); + for pair in raw.as_bytes().chunks_exact(2) { + let hi = decode_hex_nibble(pair[0]) + .ok_or_else(|| format!("{label} contains non-hex characters"))?; + let lo = decode_hex_nibble(pair[1]) + .ok_or_else(|| format!("{label} contains non-hex characters"))?; + bytes.push((hi << 4) | lo); + } + Ok(bytes) +} + +/// Decodes a single ASCII hex nibble. +pub(in crate::dispatcher) fn decode_hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Parses compact peer payloads returned by DHT `get_peers`. +pub(in crate::dispatcher) fn parse_dht_compact_peers( + values: &[Vec], +) -> Result, String> { + let mut peers = Vec::new(); + for value in values { + if value.len() % 6 != 0 { + return Err("compact peer list length must be a multiple of 6".to_owned()); + } + for chunk in value.chunks_exact(6) { + peers.push(BtPeerInfo { + peer_id: None, + ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]), + port: u16::from_be_bytes([chunk[4], chunk[5]]), + client_name: None, + interested: false, + choked: false, + download_speed: 0, + upload_speed: 0, + seeder: false, + }); + } + } + Ok(peers) +} + +/// Parses compact DHT node payload bytes into `host:port` strings. +pub(in crate::dispatcher) fn parse_dht_compact_nodes( + raw: Option<&[u8]>, +) -> Result, String> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + if raw.len() % 26 != 0 { + return Err("compact dht node list length must be a multiple of 26".to_owned()); + } + let mut nodes = Vec::new(); + for chunk in raw.chunks_exact(26) { + let ip = format!("{}.{}.{}.{}", chunk[20], chunk[21], chunk[22], chunk[23]); + let port = u16::from_be_bytes([chunk[24], chunk[25]]); + nodes.push(format!("{ip}:{port}")); + } + Ok(nodes) +} + +/// Appends newly discovered DHT nodes while preserving existing order. +pub(in crate::dispatcher) fn merge_bt_dht_nodes(existing: &mut Vec, discovered: I) +where + I: IntoIterator, +{ + for node in discovered { + if !existing.iter().any(|current| current == &node) { + existing.push(node); + } + } +} + +/// Moves a successfully used DHT node to the front of the runtime node list. +pub(in crate::dispatcher) fn promote_bt_dht_node(existing: &mut Vec, node: &DhtNodeModel) { + let entry = format!("{}:{}", node.address, node.port); + if let Some(index) = existing.iter().position(|current| current == &entry) { + if index > 0 { + let value = existing.remove(index); + existing.insert(0, value); + } + } else { + existing.insert(0, entry); + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/peer_wire.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/peer_wire.rs new file mode 100644 index 0000000..f6d699f --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/peer_wire.rs @@ -0,0 +1,360 @@ +use super::{ + BTreeMap, BTreeSet, BtPeerInfo, Digest, PeerWireBlockRequestModel, + PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind, + PeerWireMetadataMessageModel, PeerWirePieceBlockModel, PeerWireTransportRequest, + PeerWireTransportResponse, PieceState, RequestGroup, RpcError, TorrentMessageModel, + TransportEndpoint, TransportScheme, bt_metadata_piece_count, bt_peer_metadata_key, + bt_piece_span_bytes, bt_runtime_total_length, hex_string, resolve_bt_info_hash, + rpc_bt_local_node_id, +}; + +/// Identifies the peer selected for a peer-wire compatibility exchange. +#[derive(Clone, Debug)] +pub(in crate::dispatcher) struct PeerWirePeerTarget { + /// Original peer index inside the runtime peer list. + pub(in crate::dispatcher) index: usize, + /// Peer runtime snapshot used to build the outbound request. + pub(in crate::dispatcher) peer: BtPeerInfo, +} + +/// Holds the outbound peer-wire request and bookkeeping for a compatibility probe. +#[derive(Clone, Debug)] +pub(in crate::dispatcher) struct PeerWireExchangePlan { + /// Peer list index that should receive the parsed response data. + pub(in crate::dispatcher) peer_index: usize, + /// Expected info hash validated against the peer handshake. + pub(in crate::dispatcher) info_hash: [u8; 20], + /// Transport payload sent to the peer. + pub(in crate::dispatcher) request: PeerWireTransportRequest, + /// Optional block request emitted after the handshake. + pub(in crate::dispatcher) block_request: Option, + /// Optional known `ut_metadata` extension id for the selected peer. + pub(in crate::dispatcher) metadata_extension_id: Option, +} + +/// Captures peer-wire handshake and frame state recovered from a peer response. +#[derive(Clone, Debug, Default)] +pub(in crate::dispatcher) struct PeerWireExchangeResponseModel { + /// Remote peer ID emitted by the handshake when present. + pub(in crate::dispatcher) peer_id: Option, + /// Parsed remote extended handshake, when observed. + pub(in crate::dispatcher) extension_handshake: Option, + /// Latest observed choke state. + pub(in crate::dispatcher) peer_choked: Option, + /// Latest observed interest state. + pub(in crate::dispatcher) peer_interested: Option, + /// Piece set explicitly advertised by a bitfield frame. + pub(in crate::dispatcher) bitfield_pieces: Option>, + /// Aggregate set of pieces implied by bitfield, have, and piece frames. + pub(in crate::dispatcher) available_pieces: BTreeSet, + /// Piece payload frames recovered from the response. + pub(in crate::dispatcher) pieces: Vec, + /// Metadata payloads recovered from BEP 9 messages. + pub(in crate::dispatcher) metadata_messages: Vec, +} + +/// Builds the outbound peer-wire request for the current BitTorrent runtime state. +pub(in crate::dispatcher) fn build_peer_wire_exchange_plan( + group: &RequestGroup, +) -> Result { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("peer-wire exchange requires bt runtime state"))?; + let target = pick_bt_peer_target(&bt.peers)?; + let info_hash_vec = resolve_bt_info_hash(group, bt)?; + let info_hash: [u8; 20] = info_hash_vec + .as_slice() + .try_into() + .map_err(|_| RpcError::unsupported("peer-wire exchange info hash must be 20 bytes"))?; + let peer_id_vec = rpc_bt_local_node_id(group.gid()); + let peer_id: [u8; 20] = peer_id_vec + .clone() + .try_into() + .map_err(|_| RpcError::unsupported("peer-wire exchange peer id must be 20 bytes"))?; + + let peer_key = bt_peer_metadata_key(&target.peer); + let metadata_extension_id = bt.metadata_extension_ids.get(&peer_key).copied(); + let metadata_request_piece = if bt.metadata_only { + match (metadata_extension_id, bt.metadata_size) { + (Some(_), Some(metadata_size)) if metadata_size > 0 => { + let piece_count = bt_metadata_piece_count(metadata_size); + (0..piece_count).find(|piece| !bt.metadata_piece_payloads.contains_key(piece)) + } + _ => None, + } + } else { + None + }; + + let mut handshake = PeerWireHandshakeModel::new(info_hash, peer_id); + handshake.reserved[5] |= 0x10; + let mut payload = handshake.serialize(); + + let mut block_request = None; + if bt.metadata_only { + let extension_handshake = PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([("ut_metadata".to_owned(), 1_u8)]), + client_name: Some("aria2-rust-pro".to_owned()), + metadata_size: None, + request_queue: Some(16), + }; + payload.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension( + extension_handshake.to_peer_wire_message(), + )) + .serialize_peer_wire_frame() + .map_err(|error| { + RpcError::unsupported(&format!( + "peer-wire extension handshake serialization failed: {error}" + )) + })?, + ); + if let (Some(extension_message_id), Some(piece)) = + (metadata_extension_id, metadata_request_piece) + { + payload.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension( + PeerWireMetadataMessageModel::request(piece) + .to_peer_wire_message(extension_message_id), + )) + .serialize_peer_wire_frame() + .map_err(|error| { + RpcError::unsupported(&format!( + "peer-wire metadata request serialization failed: {error}" + )) + })?, + ); + } + } else { + let piece_length = group.piece_length().max(1); + let total_length = bt_runtime_total_length(group); + let requestable = group.bt_requestable_piece_ids(false, 8); + let availability = group.piece_availability(); + let selected_piece = requestable + .iter() + .copied() + .find(|piece| availability.get(piece).copied().unwrap_or(0) > 0) + .or_else(|| requestable.first().copied()); + block_request = selected_piece.map(|piece| PeerWireBlockRequestModel { + piece_index: piece.0, + block_offset: 0, + block_length: bt_piece_span_bytes(piece, piece_length, total_length) + .min(16_u64 * 1024) + .max(1) as u32, + }); + if block_request.is_some() { + payload.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Interested) + .serialize_peer_wire_frame() + .map_err(|error| { + RpcError::unsupported(&format!( + "peer-wire interested frame serialization failed: {error}" + )) + })?, + ); + } + if let Some(block_request) = &block_request { + payload.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request( + block_request.clone(), + )) + .serialize_peer_wire_frame() + .map_err(|error| { + RpcError::unsupported(&format!( + "peer-wire request frame serialization failed: {error}" + )) + })?, + ); + } + } + + Ok(PeerWireExchangePlan { + peer_index: target.index, + info_hash, + request: PeerWireTransportRequest { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: format!("{}:{}", target.peer.ip, target.peer.port), + }, + info_hash: info_hash_vec, + peer_id: peer_id_vec, + payload, + }, + block_request, + metadata_extension_id, + }) +} + +/// Chooses the best available peer target for a peer-wire exchange. +pub(super) fn pick_bt_peer_target(peers: &[BtPeerInfo]) -> Result { + if peers.is_empty() { + return Err(RpcError::unsupported( + "peer-wire exchange requires at least one bt peer", + )); + } + let mut last_error = None; + let mut fallback = None; + for (index, peer) in peers.iter().enumerate() { + if peer.ip.trim().is_empty() { + last_error = Some("peer ip must not be empty".to_owned()); + continue; + } + if peer.port == 0 { + last_error = Some("peer port must be non-zero".to_owned()); + continue; + } + let target = PeerWirePeerTarget { + index, + peer: peer.clone(), + }; + if !peer.choked { + return Ok(target); + } + if fallback.is_none() { + fallback = Some(target); + } + } + if let Some(target) = fallback { + return Ok(target); + } + Err(RpcError::unsupported(&format!( + "peer-wire exchange found no valid bt peers in runtime state{}", + last_error + .map(|message| format!(": {message}")) + .unwrap_or_default() + ))) +} + +/// Parses a peer-wire transport payload into normalized runtime update data. +pub(in crate::dispatcher) fn parse_peer_wire_exchange_response( + response: &PeerWireTransportResponse, + expected_info_hash: &[u8; 20], + known_metadata_extension_id: Option, +) -> Result { + let mut parsed = PeerWireExchangeResponseModel::default(); + let mut cursor = 0; + let mut negotiated_metadata_extension_id = known_metadata_extension_id; + if response.payload.first().copied() == Some(19) { + let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&response.payload) + .map_err(|error| { + RpcError::unsupported(&format!("invalid peer-wire handshake: {error}")) + })?; + if handshake.info_hash != *expected_info_hash { + return Err(RpcError::unsupported( + "peer-wire handshake info hash did not match download runtime state", + )); + } + parsed.peer_id = Some(hex_string(&handshake.peer_id).to_ascii_lowercase()); + cursor = consumed; + } + + while cursor < response.payload.len() { + let (frame, consumed) = TorrentMessageModel::parse_peer_wire_frame( + &response.payload[cursor..], + ) + .map_err(|error| RpcError::unsupported(&format!("invalid peer-wire frame: {error}")))?; + cursor = cursor.saturating_add(consumed); + match frame.peer_wire_kind().map_err(|error| { + RpcError::unsupported(&format!("invalid peer-wire message: {error}")) + })? { + PeerWireMessageKind::Choke => parsed.peer_choked = Some(true), + PeerWireMessageKind::Unchoke => parsed.peer_choked = Some(false), + PeerWireMessageKind::Interested => parsed.peer_interested = Some(true), + PeerWireMessageKind::NotInterested => parsed.peer_interested = Some(false), + PeerWireMessageKind::Have(piece) => { + parsed.available_pieces.insert(piece); + } + PeerWireMessageKind::Bitfield(bitfield) => { + let available = bitfield + .to_piece_flags(bitfield.piece_capacity()) + .into_iter() + .enumerate() + .filter_map(|(piece, has_piece)| has_piece.then_some(piece as u32)) + .collect::>(); + parsed.available_pieces.extend(available.iter().copied()); + parsed.bitfield_pieces = Some(available); + } + PeerWireMessageKind::Piece(piece) => { + parsed.available_pieces.insert(piece.piece_index); + parsed.pieces.push(piece); + } + PeerWireMessageKind::Extension(message) => { + if message.extension_message_id == 0 { + let handshake = PeerWireExtensionHandshakeModel::from_peer_wire_message( + &message, + ) + .map_err(|error| { + RpcError::unsupported(&format!( + "invalid peer-wire extended handshake: {error}" + )) + })?; + negotiated_metadata_extension_id = handshake + .ut_metadata_id() + .or(negotiated_metadata_extension_id); + parsed.extension_handshake = Some(handshake); + } else if negotiated_metadata_extension_id + .is_some_and(|extension_id| extension_id == message.extension_message_id) + { + let metadata_message = PeerWireMetadataMessageModel::from_peer_wire_message( + &message, + negotiated_metadata_extension_id.expect("checked is_some above"), + ) + .map_err(|error| { + RpcError::unsupported(&format!( + "invalid peer-wire ut_metadata payload: {error}" + )) + })?; + parsed.metadata_messages.push(metadata_message); + } + } + PeerWireMessageKind::KeepAlive + | PeerWireMessageKind::Request(_) + | PeerWireMessageKind::Cancel(_) + | PeerWireMessageKind::Port(_) + | PeerWireMessageKind::Unknown(_) => {} + } + } + + Ok(parsed) +} + +/// Computes how many verified bytes overlap a requested byte range. +pub(in crate::dispatcher) fn verified_length_for_range( + group: &RequestGroup, + range_start: u64, + range_length: u64, + piece_length: u64, + total_length: u64, +) -> u64 { + if range_length == 0 || piece_length == 0 || total_length == 0 { + return 0; + } + let range_end = range_start.saturating_add(range_length).min(total_length); + if range_end <= range_start { + return 0; + } + group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .map(|(piece, _)| { + let piece_start = u64::from(piece.0).saturating_mul(piece_length); + let piece_end = piece_start + .saturating_add(bt_piece_span_bytes(*piece, piece_length, total_length)) + .min(total_length); + let overlap_start = piece_start.max(range_start); + let overlap_end = piece_end.min(range_end); + overlap_end.saturating_sub(overlap_start) + }) + .sum() +} + +/// Returns whether a peer-wire bitfield covers every expected piece index. +pub(in crate::dispatcher) fn peer_wire_bitfield_is_complete( + pieces: &BTreeSet, + expected_piece_count: usize, +) -> bool { + expected_piece_count > 0 + && pieces.len() >= expected_piece_count + && (0..expected_piece_count as u32).all(|piece| pieces.contains(&piece)) +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/rpc_surface.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/rpc_surface.rs new file mode 100644 index 0000000..da4c4d4 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/rpc_surface.rs @@ -0,0 +1,91 @@ +use super::{ + AtomicU64, BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepReport, + BtRuntimeCoordinatorStepStatus, Digest, Ordering, RpcError, Sha1, SystemTime, UNIX_EPOCH, +}; + +/// Converts a successful or failed coordinator action result into a stable report row. +pub(in crate::dispatcher) fn push_bt_runtime_coordinator_result( + steps: &mut Vec, + action: BtRuntimeCoordinatorAction, + result: Result<(), RpcError>, +) { + match result { + Ok(()) => steps.push(BtRuntimeCoordinatorStepReport { + action, + status: BtRuntimeCoordinatorStepStatus::Executed, + detail: None, + }), + Err(error) => steps.push(BtRuntimeCoordinatorStepReport { + action, + status: BtRuntimeCoordinatorStepStatus::Failed, + detail: Some(error.message), + }), + } +} + +/// Builds a skipped coordinator action report row with a concise reason. +pub(in crate::dispatcher) fn skipped_bt_runtime_coordinator_step( + action: BtRuntimeCoordinatorAction, + detail: &str, +) -> BtRuntimeCoordinatorStepReport { + BtRuntimeCoordinatorStepReport { + action, + status: BtRuntimeCoordinatorStepStatus::Skipped, + detail: Some(detail.to_owned()), + } +} + +/// Monotonic nonce mixed into generated session IDs. +static NEXT_SESSION_ID_NONCE: AtomicU64 = AtomicU64::new(1); + +/// Generates a stable hex session identifier for RPC clients. +pub(in crate::dispatcher) fn generate_session_id() -> String { + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let nonce = NEXT_SESSION_ID_NONCE.fetch_add(1, Ordering::Relaxed); + let mut sha1 = Sha1::new(); + sha1.update(now_nanos.to_le_bytes()); + sha1.update(std::process::id().to_le_bytes()); + sha1.update(nonce.to_le_bytes()); + let digest = sha1.finalize(); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Returns the upstream-style enabled feature list reported by `getVersion`. +pub(in crate::dispatcher) fn rpc_enabled_features() -> &'static [&'static str] { + &[ + "Async DNS", + "BitTorrent", + "GZip", + "HTTPS", + "Message Digest", + "Metalink", + "XML-RPC", + "SFTP", + ] +} + +/// Encodes bytes as an uppercase hexadecimal string. +pub(in crate::dispatcher) fn hex_string(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02X}")); + } + out +} + +/// Formats the share ratio text expected by aria2 RPC payloads. +pub(in crate::dispatcher) fn rpc_share_ratio_text(share_ratio_milli: Option) -> String { + share_ratio_milli + .map(|milli| format!("{:.3}", milli as f64 / 1000.0)) + .unwrap_or_else(|| "0.000".to_owned()) +} + +/// Formats the share time text expected by aria2 RPC payloads. +pub(in crate::dispatcher) fn rpc_share_time_text( + snapshot: &aria2_rust_pro_core::ProgressSnapshot, +) -> String { + snapshot.share_time_secs.unwrap_or(0).to_string() +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/selection.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/selection.rs new file mode 100644 index 0000000..c62f080 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/selection.rs @@ -0,0 +1,74 @@ +use super::{BTreeSet, Digest, RequestGroup}; + +/// Applies a `select-file` option value to BitTorrent file selection state. +pub(in crate::dispatcher) fn apply_bt_select_file_option( + group: &mut RequestGroup, + select_file: &str, +) -> Result<(), String> { + let Some(mut bt_state) = group.bt().cloned() else { + return Ok(()); + }; + if bt_state.files.is_empty() { + return Ok(()); + } + let selected_indexes = parse_bt_select_file_indexes(select_file, bt_state.files.len())?; + for (index, file) in bt_state.files.iter_mut().enumerate() { + file.selected = selected_indexes.contains(&(index + 1)); + } + group.set_bt(bt_state); + Ok(()) +} + +/// Parses aria2-style `select-file` syntax into a set of selected file indexes. +pub(in crate::dispatcher) fn parse_bt_select_file_indexes( + select_file: &str, + file_count: usize, +) -> Result, String> { + let mut selected = BTreeSet::new(); + let trimmed = select_file.trim(); + if trimmed.is_empty() { + return Err("empty value".to_owned()); + } + for token in trimmed + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + { + if let Some((start_raw, end_raw)) = token.split_once('-') { + let start = start_raw + .trim() + .parse::() + .map_err(|_| format!("invalid start index `{start_raw}`"))?; + let end = end_raw + .trim() + .parse::() + .map_err(|_| format!("invalid end index `{end_raw}`"))?; + if start == 0 || end == 0 { + return Err("indexes are 1-based".to_owned()); + } + let (lo, hi) = if start <= end { + (start, end) + } else { + (end, start) + }; + if hi > file_count { + return Err(format!("index {hi} out of range 1..={file_count}")); + } + for index in lo..=hi { + selected.insert(index); + } + continue; + } + let index = token + .parse::() + .map_err(|_| format!("invalid index `{token}`"))?; + if index == 0 { + return Err("indexes are 1-based".to_owned()); + } + if index > file_count { + return Err(format!("index {index} out of range 1..={file_count}")); + } + selected.insert(index); + } + Ok(selected) +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/tracker.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/tracker.rs new file mode 100644 index 0000000..0a862f3 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/tracker.rs @@ -0,0 +1,40 @@ +use super::{RequestGroup, RpcError, TrackerRequestModel, rpc_bt_info_hash}; + +/// Builds a tracker announce request from the current BitTorrent runtime snapshot. +pub(in crate::dispatcher) fn build_tracker_request( + group: &RequestGroup, +) -> Result { + let bt = group + .bt() + .ok_or_else(|| RpcError::unsupported("tracker announce requires bt runtime state"))?; + let announce_url = bt + .trackers + .first() + .map(|tracker| tracker.url.clone()) + .ok_or_else(|| RpcError::unsupported("tracker announce requires at least one tracker"))?; + let info_hash = if !bt.info_hash.is_empty() { + bt.info_hash.clone() + } else { + rpc_bt_info_hash(group.uri()).unwrap_or_default() + }; + if info_hash.len() != 40 || !info_hash.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(RpcError::unsupported( + "tracker announce requires a 40-character hex info hash", + )); + } + let selected_total = group + .bt_selected_total_length() + .unwrap_or_else(|| group.total_length()); + Ok(TrackerRequestModel { + announce_url, + info_hash, + peer_id: format!("{:040x}", group.gid().as_u64()), + port: 6881, + uploaded: group.upload_length(), + downloaded: group.completed_length(), + left: selected_total.saturating_sub(group.completed_length()), + event: Some("started".to_owned()), + compact: true, + numwant: Some(50), + }) +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/dispatch_surface.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/dispatch_surface.rs new file mode 100644 index 0000000..f84db42 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/dispatch_surface.rs @@ -0,0 +1,264 @@ +use crate::{ + handlers::RpcHandlerContext, + jsonrpc::{JsonRpcRequest, JsonRpcResponse}, + model::{RpcAuthContext, RpcError, RpcMeta, RpcValue}, + router::{RpcDispatchRequest, RpcDispatchResult}, + xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue}, +}; + +use super::{ + InProcessRpcDispatcher, + faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value}, + helpers::xmlrpc_member_value, +}; + +impl InProcessRpcDispatcher { + #[must_use] + /// Dispatches a JSON-RPC request through the in-process engine. + pub fn dispatch_json(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "aria2.addUri" => self.handle_add_uri(request), + "aria2.addTorrent" => self.handle_add_torrent(request), + "aria2.addMetalink" => self.handle_add_metalink(request), + "aria2.tellStatus" => self.handle_tell_status(request), + "aria2.tellWaiting" => self.handle_tell_waiting(request), + "aria2.tellStopped" => self.handle_tell_stopped(request), + "aria2.pause" => self.handle_state_transition( + request, + "aria2.pause", + aria2_rust_pro_core::DownloadEngine::pause, + ), + "aria2.forcePause" => self.handle_state_transition( + request, + "aria2.forcePause", + aria2_rust_pro_core::DownloadEngine::pause, + ), + "aria2.unpause" => self.handle_state_transition( + request, + "aria2.unpause", + aria2_rust_pro_core::DownloadEngine::resume, + ), + "aria2.remove" => self.handle_state_transition( + request, + "aria2.remove", + aria2_rust_pro_core::DownloadEngine::remove, + ), + "aria2.forceRemove" => self.handle_state_transition( + request, + "aria2.forceRemove", + aria2_rust_pro_core::DownloadEngine::remove, + ), + "aria2.pauseAll" => self.handle_pause_all(request), + "aria2.forcePauseAll" => self.handle_pause_all(request), + "aria2.unpauseAll" => self.handle_unpause_all(request), + "aria2.tellActive" => self.handle_tell_active(request), + "aria2.getGlobalStat" | "aria2.tellGlobalStat" => self.handle_tell_global_stat(request), + "aria2.getGlobalOption" => self.handle_get_global_option(request), + "aria2.changeGlobalOption" => self.handle_change_global_option(request), + "aria2.getOption" => self.handle_get_option(request), + "aria2.changeOption" => self.handle_change_option(request), + "aria2.getUris" => self.handle_get_uris(request), + "aria2.getFiles" => self.handle_get_files(request), + "aria2.getPeers" => self.handle_get_peers(request), + "aria2.getServers" => self.handle_get_servers(request), + "aria2.changePosition" => self.handle_change_position(request), + "aria2.changeUri" => self.handle_change_uri(request), + "aria2.purgeDownloadResult" => self.handle_purge_download_result(request), + "aria2.removeDownloadResult" => self.handle_remove_download_result(request), + "aria2.getSessionInfo" => self.handle_get_session_info(request), + "aria2.saveSession" => self.handle_save_session(request), + "aria2.shutdown" => self.handle_shutdown(request), + "aria2.forceShutdown" => self.handle_force_shutdown(request), + "system.multicall" | "aria2.multicall" => self.handle_multicall(request), + "aria2.getVersion" => JsonRpcResponse::success(request.id, self.rpc_version_payload()), + _ => { + let ctx = RpcHandlerContext { + auth: RpcAuthContext::default(), + meta: request.meta.clone(), + }; + match self + .router + .dispatch(RpcDispatchRequest::Json(request.clone()), ctx) + { + RpcDispatchResult::Json(response) => response, + RpcDispatchResult::Xml(_) | RpcDispatchResult::Empty => { + JsonRpcResponse::success(request.id, RpcValue::Null) + } + } + } + } + } + + #[must_use] + /// Dispatches an XML-RPC request through the in-process engine. + pub fn dispatch_xml(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse { + if request.method_name == "aria2.getVersion" { + return XmlRpcMethodResponse { + value: Some(crate::xmlrpc::rpc_value_to_xmlrpc( + self.rpc_version_payload(), + )), + fault: None, + meta: RpcMeta::default(), + }; + } + if request.method_name == "aria2.getSessionInfo" { + return XmlRpcMethodResponse { + value: Some(XmlRpcValue::Struct(vec![XmlRpcMember { + name: "sessionId".to_owned(), + value: XmlRpcValue::String(self.session_id.clone()), + }])), + fault: None, + meta: RpcMeta::default(), + }; + } + if request.method_name == "system.multicall" { + return self.handle_xml_multicall(request); + } + let json_request = JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: request.method_name, + params: request + .params + .into_iter() + .map(|param| crate::xmlrpc::xmlrpc_value_to_rpc(param.value)) + .collect(), + meta: request.meta, + }; + let response = self.dispatch_json(json_request); + XmlRpcMethodResponse { + value: response.result.map(crate::xmlrpc::rpc_value_to_xmlrpc), + fault: response.error.map(xmlrpc_fault_from_error), + meta: RpcMeta::default(), + } + } + + /// Dispatches XML-RPC multicall entries and wraps each response in upstream-compatible arrays. + fn handle_xml_multicall(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse { + let Some(first) = request.params.first() else { + return XmlRpcMethodResponse { + value: None, + fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params( + "system.multicall requires method specs", + ))), + meta: RpcMeta::default(), + }; + }; + let XmlRpcValue::Array(method_specs) = &first.value else { + return XmlRpcMethodResponse { + value: None, + fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params( + "system.multicall expected array of method specs", + ))), + meta: RpcMeta::default(), + }; + }; + + let mut results = Vec::with_capacity(method_specs.len()); + for method_spec in method_specs { + let XmlRpcValue::Struct(spec) = method_spec else { + results.push(xmlrpc_error_value(RpcError::invalid_params( + "system.multicall expected struct.", + ))); + continue; + }; + let Some(XmlRpcValue::String(method_name)) = xmlrpc_member_value(spec, "methodName") + else { + results.push(xmlrpc_error_value(RpcError::invalid_params( + "Missing methodName.", + ))); + continue; + }; + if method_name == "system.multicall" { + results.push(xmlrpc_error_value(RpcError::invalid_params( + "Recursive system.multicall forbidden.", + ))); + continue; + } + let params = match xmlrpc_member_value(spec, "params") { + Some(XmlRpcValue::Array(params)) => params + .iter() + .cloned() + .map(|value| XmlRpcParam { value }) + .collect(), + _ => Vec::new(), + }; + let response = self.dispatch_xml(XmlRpcMethodCall { + method_name: method_name.clone(), + params, + meta: request.meta.clone(), + }); + if let Some(fault) = response.fault { + results.push(xmlrpc_fault_value(fault)); + } else { + results.push(XmlRpcValue::Array(vec![ + response.value.unwrap_or(XmlRpcValue::Nil), + ])); + } + } + + XmlRpcMethodResponse { + value: Some(XmlRpcValue::Array(results)), + fault: None, + meta: RpcMeta::default(), + } + } + + /// Handles JSON-RPC multicall requests while preserving per-call result ordering. + fn handle_multicall(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let Some(first) = request.params.first() else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("system.multicall requires method specs"), + ); + }; + let RpcValue::Array(method_specs) = first else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("system.multicall expected array of method specs"), + ); + }; + + let mut results = Vec::with_capacity(method_specs.len()); + for method_spec in method_specs { + let RpcValue::Object(spec) = method_spec else { + results.push(rpc_error_value(RpcError::invalid_params( + "system.multicall expected struct.", + ))); + continue; + }; + let Some(RpcValue::String(method_name)) = spec.get("methodName") else { + results.push(rpc_error_value(RpcError::invalid_params( + "Missing methodName.", + ))); + continue; + }; + if method_name == "system.multicall" || method_name == "aria2.multicall" { + results.push(rpc_error_value(RpcError::invalid_params( + "Recursive system.multicall forbidden.", + ))); + continue; + } + let params = match spec.get("params") { + Some(RpcValue::Array(params)) => params.clone(), + _ => Vec::new(), + }; + let response = self.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method_name.clone(), + params, + meta: request.meta.clone(), + }); + if let Some(error) = response.error { + results.push(rpc_error_value(error)); + } else { + results.push(RpcValue::Array(vec![ + response.result.unwrap_or(RpcValue::Null), + ])); + } + } + + JsonRpcResponse::success(request.id, RpcValue::Array(results)) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/faults.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/faults.rs new file mode 100644 index 0000000..436fa00 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/faults.rs @@ -0,0 +1,46 @@ +use std::collections::BTreeMap; + +use crate::{ + model::{RpcError, RpcValue}, + xmlrpc::{XmlRpcMember, XmlRpcValue}, +}; + +/// Converts an RPC error into the JSON-RPC error-object shape used by multicall. +pub(super) fn rpc_error_value(error: RpcError) -> RpcValue { + RpcValue::Object(BTreeMap::from([ + ( + "code".to_owned(), + RpcValue::Number(i64::from(error.code as i32)), + ), + ("message".to_owned(), RpcValue::String(error.message)), + ])) +} + +/// Converts an RPC error into the XML-RPC fault-value shape used by multicall. +pub(super) fn xmlrpc_error_value(error: RpcError) -> XmlRpcValue { + xmlrpc_fault_value(xmlrpc_fault_from_error(error)) +} + +/// Serializes an XML-RPC fault struct into a value payload. +pub(super) fn xmlrpc_fault_value(fault: crate::xmlrpc::XmlRpcFault) -> XmlRpcValue { + XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "faultCode".to_owned(), + value: XmlRpcValue::Int(fault.code), + }, + XmlRpcMember { + name: "faultString".to_owned(), + value: XmlRpcValue::String(fault.message), + }, + ]) +} + +/// Wraps an RPC error in the XML-RPC fault envelope expected by aria2 clients. +pub(super) fn xmlrpc_fault_from_error(error: RpcError) -> crate::xmlrpc::XmlRpcFault { + let message = error.message.clone(); + crate::xmlrpc::XmlRpcFault { + code: 1, + message, + error: Some(error), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/helpers.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/helpers.rs new file mode 100644 index 0000000..ef9ed6c --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/helpers.rs @@ -0,0 +1,356 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use aria2_rust_pro_compat::{global_option_specs, per_download_option_specs}; +use aria2_rust_pro_core::{DownloadHandle, RequestGroup}; +use base64::Engine; + +use crate::{ + model::RpcValue, + xmlrpc::{XmlRpcMember, XmlRpcValue}, +}; + +/// Builds the unified option surface exposed by `getGlobalOption`. +pub(super) fn option_specs_for_global_view() -> Vec<&'static aria2_rust_pro_compat::OptionSpec> { + let mut specs = global_option_specs(); + let mut seen = specs.iter().map(|spec| spec.name).collect::>(); + for spec in per_download_option_specs() { + if seen.insert(spec.name) { + specs.push(spec); + } + } + specs +} + +/// Lossily converts a `usize` into an `i64` for RPC payload rendering. +pub(super) fn i64_from_usize(value: usize) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +/// Lossily converts a `usize` into a `u32` for engine-facing counters. +pub(super) fn u32_from_usize(value: usize) -> u32 { + u32::try_from(value).unwrap_or(u32::MAX) +} + +/// Lossily converts a `usize` into a `u64` for RPC payload rendering. +pub(super) fn u64_from_usize(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +/// Converts a non-negative JSON-RPC integer into a platform `usize`. +pub(super) fn usize_from_i64(value: i64) -> Option { + usize::try_from(value).ok() +} + +/// Lossily converts a `u64` into a `usize` for local indexing. +pub(super) fn usize_from_u64(value: u64) -> usize { + usize::try_from(value).unwrap_or(usize::MAX) +} + +/// Returns the first per-download option key that aria2 forbids through `changeOption`. +pub(super) fn first_forbidden_change_option_key(map: &BTreeMap) -> Option<&str> { + const FORBIDDEN: &[&str] = &[ + "dry-run", + "metalink-base-uri", + "parameterized-uri", + "pause", + "piece-length", + "rpc-save-upload-metadata", + ]; + FORBIDDEN + .iter() + .find_map(|name| map.contains_key(*name).then_some(*name)) +} + +/// Returns the first global option key that aria2 forbids through `changeGlobalOption`. +pub(super) fn first_forbidden_change_global_option_key( + map: &BTreeMap, +) -> Option<&str> { + const FORBIDDEN: &[&str] = &["checksum", "index-out", "out", "pause", "select-file"]; + FORBIDDEN + .iter() + .find_map(|name| map.contains_key(*name).then_some(*name)) +} + +/// Parses a required RPC URI parameter into a normalized URI list. +pub(super) fn parse_uri_list_param(value: &RpcValue) -> Result, String> { + match value { + RpcValue::String(uri) => Ok(vec![uri.clone()]), + RpcValue::Array(items) => { + let mut uris = Vec::with_capacity(items.len()); + for item in items { + match item { + RpcValue::String(uri) => uris.push(uri.clone()), + _ => return Err("uri array must contain only strings".to_owned()), + } + } + if uris.is_empty() { + return Err("uri array must not be empty".to_owned()); + } + Ok(uris) + } + _ => Err("uris must be an array of strings".to_owned()), + } +} + +/// Parses a URI array parameter that may legally be empty. +pub(super) fn parse_uri_array_allow_empty( + value: &RpcValue, + label: &str, +) -> Result, String> { + let RpcValue::Array(items) = value else { + return Err(format!("{label} must be an array of strings")); + }; + let mut uris = Vec::with_capacity(items.len()); + for item in items { + if let RpcValue::String(uri) = item { + uris.push(uri.clone()); + } + } + Ok(uris) +} + +/// Parses an optional webseed URI array for add-torrent style methods. +pub(super) fn parse_optional_uri_array( + value: &RpcValue, + method: &str, +) -> Result, String> { + match value { + RpcValue::Array(items) => { + let mut uris = Vec::with_capacity(items.len()); + for item in items { + match item { + RpcValue::String(uri) => uris.push(uri.clone()), + _ => { + return Err(format!("{method} webseed uris must contain only strings")); + } + } + } + Ok(uris) + } + _ => Err(format!("{method} webseed uris must be an array of strings")), + } +} + +/// Parses an optional RPC options object into owned key-value entries. +pub(super) fn parse_optional_option_object( + value: Option<&RpcValue>, + method: &str, +) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let RpcValue::Object(options) = value else { + return Err(format!("{method} options must be a struct/object")); + }; + Ok(options + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) +} + +/// Parses an optional queue position parameter. +pub(super) fn parse_optional_position( + value: Option<&RpcValue>, + method: &str, +) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + match value { + RpcValue::Number(value) if *value >= 0 => Ok(usize_from_i64(*value)), + _ => Err(format!("{method} position must be a non-negative integer")), + } +} + +/// Parses a required 1-based file index parameter. +pub(super) fn parse_required_file_index(value: &RpcValue) -> Option { + match value { + RpcValue::Number(value) if *value >= 1 => usize_from_i64(*value), + _ => None, + } +} + +/// Returns whether a URI looks actionable for aria2-style add methods. +pub(super) fn is_rpc_uri_candidate(uri: &str) -> bool { + rpc_uri_has_ascii_prefix(uri, "magnet:?") + || uri.contains("://") + || rpc_uri_has_ascii_suffix(uri, ".torrent") +} + +/// Returns whether a URI starts with an ASCII prefix, ignoring case. +pub(super) fn rpc_uri_has_ascii_prefix(uri: &str, prefix: &str) -> bool { + uri.get(..prefix.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) +} + +/// Returns whether a URI ends with an ASCII suffix, ignoring case. +pub(super) fn rpc_uri_has_ascii_suffix(uri: &str, suffix: &str) -> bool { + uri.get(uri.len().saturating_sub(suffix.len())..) + .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix)) +} + +/// Extracts a display file name from a URI when one is obvious. +pub(super) fn rpc_uri_file_name(uri: &str) -> Option { + 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()) + } +} + +/// Parses an optional status-field allowlist parameter. +pub(super) fn parse_optional_status_keys( + value: Option<&RpcValue>, + method: &str, +) -> Result>, String> { + let Some(value) = value else { + return Ok(None); + }; + let RpcValue::Array(items) = value else { + return Err(format!("{method} keys must be an array of strings")); + }; + if items.is_empty() { + return Ok(None); + } + let mut keys = BTreeSet::new(); + for item in items { + match item { + RpcValue::String(key) => { + keys.insert(key.clone()); + } + _ => return Err(format!("{method} keys must contain only strings")), + } + } + Ok(Some(keys)) +} + +/// Filters a status payload down to the requested field set. +pub(super) fn filter_status_payload( + payload: RpcValue, + keys: Option<&BTreeSet>, +) -> RpcValue { + let Some(keys) = keys else { + return payload; + }; + match payload { + RpcValue::Object(fields) => RpcValue::Object( + fields + .into_iter() + .filter(|(key, _)| keys.contains(key)) + .collect(), + ), + other => other, + } +} + +/// Applies RPC option values to a request group using aria2's stringly option model. +pub(super) fn apply_group_options(group: &mut RequestGroup, options: Vec<(String, RpcValue)>) { + for (key, value) in options { + match value { + RpcValue::String(value) => group.set_option(key, value), + RpcValue::Number(value) => group.set_option(key, value.to_string()), + RpcValue::Bool(value) => group.set_option(key, if value { "true" } else { "false" }), + RpcValue::Null => group.set_option(key, ""), + RpcValue::Array(_) | RpcValue::Object(_) => {} + } + } +} + +/// Applies already-normalized string options to a request group directly. +pub(super) fn apply_group_string_options(group: &mut RequestGroup, options: Vec<(String, String)>) { + for (key, value) in options { + group.set_option(key, value); + } +} + +/// Builds implied request-group options from a metalink plan entry. +pub(super) fn metalink_default_options( + entry: &aria2_rust_pro_protocol::metalink::MetalinkDownloadPlanEntry, +) -> Vec<(String, RpcValue)> { + let mut options = Vec::new(); + if !entry.file_name.trim().is_empty() { + options.push(("out".to_owned(), RpcValue::String(entry.file_name.clone()))); + } + if let Some(checksum) = &entry.checksum { + options.push(( + "checksum".to_owned(), + RpcValue::String(format!("{}={}", checksum.algorithm, checksum.expected_hex)), + )); + } + options +} + +/// Decodes a base64 metalink payload when the caller did not send raw XML. +pub(super) fn decode_metalink_payload(value: &str) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.as_bytes()) + .ok()?; + let text = String::from_utf8(bytes).ok()?; + text.contains(", + offset: i64, + max: usize, +) -> Vec { + if max == 0 || handles.is_empty() { + return Vec::new(); + } + + if offset >= 0 { + return handles + .into_iter() + .skip(usize_from_i64(offset).unwrap_or(usize::MAX)) + .take(max) + .collect(); + } + + let reversed = handles.into_iter().rev().collect::>(); + let start = offset + .checked_neg() + .and_then(|value| value.checked_sub(1)) + .and_then(usize_from_i64) + .unwrap_or_default(); + reversed.into_iter().skip(start).take(max).collect() +} + +/// Looks up a named XML-RPC struct member. +pub(super) fn xmlrpc_member_value<'a>( + members: &'a [XmlRpcMember], + name: &str, +) -> Option<&'a XmlRpcValue> { + members + .iter() + .find(|member| member.name == name) + .map(|member| &member.value) +} + +/// Parses the completed byte count from a `Content-Range` header value. +pub(super) fn parse_content_range_completed_length(value: &str) -> Option { + let mut parts = value.split_whitespace(); + let unit = parts.next()?; + if !unit.eq_ignore_ascii_case("bytes") { + return None; + } + let range = parts.next()?; + let (start, end) = range.split_once('-')?; + let start = start.parse::().ok()?; + let end = end.parse::().ok()?; + if end < start { + return None; + } + Some(end - start + 1) +} + +/// Returns whether an HTTP status should preserve retry eligibility. +pub(super) fn is_retry_relevant_status(status: u16) -> bool { + matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504) +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/mutations.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/mutations.rs new file mode 100644 index 0000000..b8ac321 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/mutations.rs @@ -0,0 +1,362 @@ +use super::compat_support::apply_bt_select_file_option; +use super::{ + CoreError, DownloadEngine, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest, + JsonRpcResponse, QueuePositionMode, RpcError, RpcValue, SaveSessionTarget, + first_forbidden_change_global_option_key, first_forbidden_change_option_key, i64_from_usize, + is_rpc_uri_candidate, parse_optional_position, parse_required_file_index, + parse_uri_array_allow_empty, state_transition_rpc_error, +}; + +impl InProcessRpcDispatcher { + /// Handles `aria2.changeGlobalOption` after rejecting unsupported dynamic keys. + pub(super) fn handle_change_global_option( + &mut self, + request: JsonRpcRequest, + ) -> JsonRpcResponse { + let Some(RpcValue::Object(map)) = request.params.first() else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changeGlobalOption needs option object"), + ); + }; + if let Some(option) = first_forbidden_change_global_option_key(map) { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!( + "aria2.changeGlobalOption does not allow dynamic updates for option: {option}" + )), + ); + } + let patch = self.rpc_object_to_patch(map.clone()); + self.engine.apply_options(patch); + JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())) + } + + /// Handles `aria2.changeOption` by applying validated per-download option patches. + pub(super) fn handle_change_option(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.changeOption") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + let Some(RpcValue::Object(map)) = request.params.get(1) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changeOption needs option object"), + ); + }; + if let Some(option) = first_forbidden_change_option_key(map) { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!( + "aria2.changeOption does not allow dynamic updates for option: {option}" + )), + ); + } + let patch = self.rpc_object_to_patch(map.clone()); + let select_file_option = map + .get("select-file") + .map(|value| self.option_value_text(&self.rpc_value_to_option_value(value.clone()))); + match self.engine.handle_mut(gid) { + Some(group) => { + if let Some(select_file_value) = select_file_option + && let Err(message) = apply_bt_select_file_option(group, &select_file_value) + { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!("invalid select-file option: {message}")), + ); + } + group.options_mut().merge(patch); + JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())) + } + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("Cannot change option for GID#{gid}")), + ), + } + } + + /// Handles `aria2.changePosition` by moving waiting downloads within the queue. + pub(super) fn handle_change_position(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.changePosition") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + let Some(position) = request.params.get(1).and_then(|value| match value { + RpcValue::Number(value) => Some(*value), + _ => None, + }) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changePosition needs position"), + ); + }; + let Some(mode_text) = request.params.get(2).and_then(|value| match value { + RpcValue::String(value) => Some(value.as_str()), + _ => None, + }) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changePosition needs mode"), + ); + }; + let mode = match mode_text { + "POS_SET" => QueuePositionMode::Set, + "POS_CUR" => QueuePositionMode::Cur, + "POS_END" => QueuePositionMode::End, + _ => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("Illegal argument."), + ); + } + }; + match self.engine.change_position(gid, position, mode) { + Ok(dest) => { + JsonRpcResponse::success(request.id, RpcValue::Number(i64_from_usize(dest))) + } + Err(_) => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("GID#{gid} not found in the waiting queue.")), + ), + } + } + + /// Handles `aria2.changeUri` by removing and inserting source URIs for a download. + pub(super) fn handle_change_uri(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.changeUri") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + let Some(file_index) = request.params.get(1).and_then(parse_required_file_index) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changeUri needs fileIndex"), + ); + }; + let Some(del_uris_value) = request.params.get(2) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changeUri needs delUris"), + ); + }; + let del_uris = match parse_uri_array_allow_empty(del_uris_value, "aria2.changeUri delUris") + { + Ok(uris) => uris, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + let Some(add_uris_value) = request.params.get(3) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.changeUri needs addUris"), + ); + }; + let add_uris = match parse_uri_array_allow_empty(add_uris_value, "aria2.changeUri addUris") + { + Ok(uris) => uris, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + let position = match parse_optional_position(request.params.get(4), "aria2.changeUri") { + Ok(position) => position, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + if file_index != 1 { + return JsonRpcResponse::error( + request.id, + RpcError::unsupported("fileIndex is out of range"), + ); + } + let Some(group) = self.engine.handle_mut(gid) else { + return JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("Cannot remove URIs from GID#{gid}")), + ); + }; + let mut deleted = 0_i64; + for uri in del_uris { + if group.context_mut().remove_first_matching_uri(&uri) { + deleted += 1; + } + } + let mut inserted = 0_i64; + if let Some(mut position) = position { + for uri in add_uris { + if !is_rpc_uri_candidate(&uri) { + continue; + } + group.context_mut().insert_uri(position, uri); + position += 1; + inserted += 1; + } + } else { + for uri in add_uris { + if !is_rpc_uri_candidate(&uri) { + continue; + } + group.context_mut().append_uri(uri); + inserted += 1; + } + } + JsonRpcResponse::success( + request.id, + RpcValue::Array(vec![RpcValue::Number(deleted), RpcValue::Number(inserted)]), + ) + } + + /// Handles `aria2.purgeDownloadResult` by removing all stopped download results. + pub(super) fn handle_purge_download_result( + &mut self, + request: JsonRpcRequest, + ) -> JsonRpcResponse { + self.engine.purge_download_results(); + JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())) + } + + /// Handles `aria2.removeDownloadResult` for a single stopped download result. + pub(super) fn handle_remove_download_result( + &mut self, + request: JsonRpcRequest, + ) -> JsonRpcResponse { + let Some(gid_text) = request.params.first().and_then(|value| match value { + RpcValue::String(gid) => Some(gid.clone()), + _ => None, + }) else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.removeDownloadResult needs gid"), + ); + }; + let Some(gid) = DownloadId::parse_hex(&gid_text) else { + return JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("Invalid GID {gid_text}")), + ); + }; + match self.engine.remove_download_result(gid) { + Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())), + Err(_) => JsonRpcResponse::error( + request.id, + RpcError { + code: crate::model::RpcErrorCode::ApplicationError, + kind: crate::model::RpcErrorKind::Internal, + message: format!("Could not remove download result of GID#{gid_text}"), + }, + ), + } + } + + /// Handles `aria2.saveSession` by writing the runtime session target when configured. + pub(super) fn handle_save_session(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let target = self + .engine + .session() + .session_file() + .cloned() + .map(SaveSessionTarget::Path) + .unwrap_or(SaveSessionTarget::Memory); + match self.engine.save_session(target) { + Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())), + Err(error) => { + JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string())) + } + } + } + + /// Handles graceful shutdown requests with the aria2 success sentinel. + pub(super) fn handle_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + match self.engine.shutdown() { + Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())), + Err(error) => { + JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string())) + } + } + } + + /// Handles forced shutdown requests with the aria2 success sentinel. + pub(super) fn handle_force_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + match self.engine.force_shutdown() { + Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())), + Err(error) => { + JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string())) + } + } + } + + /// Handles pause-all variants by pausing eligible active or waiting downloads. + pub(super) fn handle_pause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let gids: Vec<_> = self + .engine + .registry() + .handles() + .filter(|handle| { + self.engine + .registry() + .get(handle.gid()) + .is_some_and(|group| { + matches!( + group.status(), + DownloadStatus::Active | DownloadStatus::Waiting + ) + }) + }) + .map(|handle| handle.gid()) + .collect(); + for gid in gids { + let _ = self.engine.pause(gid); + } + JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())) + } + + /// Handles `aria2.unpauseAll` by resuming eligible paused downloads. + pub(super) fn handle_unpause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let gids: Vec<_> = self + .engine + .registry() + .handles() + .filter(|handle| { + self.engine + .registry() + .get(handle.gid()) + .is_some_and(|group| group.status() == &DownloadStatus::Paused) + }) + .map(|handle| handle.gid()) + .collect(); + for gid in gids { + let _ = self.engine.resume(gid); + } + JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())) + } + + /// Handles single-download pause, resume, and remove state transitions. + pub(super) fn handle_state_transition( + &mut self, + request: JsonRpcRequest, + method: &'static str, + mut apply: F, + ) -> JsonRpcResponse + where + F: FnMut(&mut DownloadEngine, DownloadId) -> Result<(), CoreError>, + { + let Some(RpcValue::String(gid)) = request.params.first() else { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(method)); + }; + let Some(gid) = DownloadId::parse_hex(gid) else { + return JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("Invalid GID {gid}")), + ); + }; + match apply(&mut self.engine, gid) { + Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String(gid.to_string())), + Err(error) => { + JsonRpcResponse::error(request.id, state_transition_rpc_error(method, gid, &error)) + } + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/payloads.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/payloads.rs new file mode 100644 index 0000000..19881d5 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/payloads.rs @@ -0,0 +1,785 @@ +use aria2_rust_pro_compat::per_download_option_specs; + +use super::{ + BT_STATUS_FIELDS, BTreeMap, BtFileInfo, BtTrackerInfo, Digest, DownloadStatus, + InProcessRpcDispatcher, OptionKey, OptionPatch, OptionValue, PieceId, PieceState, RequestGroup, + RpcValue, option_specs_for_global_view, rpc_bt_info_hash, rpc_share_ratio_text, + rpc_share_time_text, rpc_uri_file_name, rpc_uri_has_ascii_prefix, rpc_uri_has_ascii_suffix, + verified_length_for_range, +}; + +impl InProcessRpcDispatcher { + /// Builds the full aria2 `tellStatus` object for a request group. + pub(super) fn rpc_status_payload(&self, group: &RequestGroup) -> RpcValue { + let snapshot = self + .engine + .progress_snapshot(group.gid()) + .unwrap_or_else(|_| { + aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), *group.status()) + }); + let piece_length = group.piece_length().max(1); + let total_length = snapshot.total_length; + let num_pieces = if total_length == 0 { + 0 + } else { + total_length.div_ceil(piece_length) + }; + let completed_pieces = group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .count() + .try_into() + .unwrap_or(u64::MAX); + let mut status = BTreeMap::from([ + ("gid".to_owned(), RpcValue::String(group.gid().to_string())), + ( + "status".to_owned(), + RpcValue::String(self.rpc_status_name(group.status()).to_owned()), + ), + ( + "totalLength".to_owned(), + RpcValue::String(snapshot.total_length.to_string()), + ), + ( + "completedLength".to_owned(), + RpcValue::String(snapshot.completed_length.to_string()), + ), + ( + "uploadLength".to_owned(), + RpcValue::String(snapshot.upload_length.to_string()), + ), + ( + "uploadSpeed".to_owned(), + RpcValue::String(snapshot.upload_speed.to_string()), + ), + ( + "shareRatio".to_owned(), + RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)), + ), + ( + "shareRatioProgress".to_owned(), + RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)), + ), + ( + "shareRatioRemaining".to_owned(), + RpcValue::String("0.000".to_owned()), + ), + ( + "shareTime".to_owned(), + RpcValue::String(rpc_share_time_text(&snapshot)), + ), + ( + "downloadSpeed".to_owned(), + RpcValue::String(snapshot.download_speed.to_string()), + ), + ( + "retryCount".to_owned(), + RpcValue::String(group.retry_count().to_string()), + ), + ( + "retryAttempts".to_owned(), + RpcValue::Array( + group + .retry_attempts() + .iter() + .map(|attempt| { + RpcValue::Object(BTreeMap::from([ + ( + "attempt".to_owned(), + RpcValue::String(attempt.attempt.to_string()), + ), + ( + "offset".to_owned(), + RpcValue::String(attempt.offset.to_string()), + ), + ( + "length".to_owned(), + RpcValue::String( + attempt.length.unwrap_or_default().to_string(), + ), + ), + ( + "recoverable".to_owned(), + RpcValue::Bool(attempt.recoverable), + ), + ( + "error".to_owned(), + RpcValue::String(attempt.error.clone().unwrap_or_default()), + ), + ])) + }) + .collect(), + ), + ), + ( + "numSeeders".to_owned(), + RpcValue::String(self.rpc_bt_num_seeders(group).to_string()), + ), + ( + "seeders".to_owned(), + RpcValue::String(self.rpc_bt_num_seeders(group).to_string()), + ), + ( + "connections".to_owned(), + RpcValue::String(snapshot.num_connections.to_string()), + ), + ( + "activeSegments".to_owned(), + RpcValue::String(snapshot.num_connections.to_string()), + ), + ( + "pieceLength".to_owned(), + RpcValue::String(piece_length.to_string()), + ), + ( + "numPieces".to_owned(), + RpcValue::String(num_pieces.to_string()), + ), + ( + "completedPieces".to_owned(), + RpcValue::String(completed_pieces.to_string()), + ), + ("errorCode".to_owned(), RpcValue::String("0".to_owned())), + ("dir".to_owned(), RpcValue::String(String::new())), + ( + "resumeState".to_owned(), + self.rpc_resume_state_payload(group), + ), + ( + "files".to_owned(), + RpcValue::Array(vec![self.rpc_file_payload(group)]), + ), + ]); + status.extend(self.rpc_bt_status_fields(group)); + RpcValue::Object(status) + } + + /// Maps internal download states to aria2 status names. + pub(super) fn rpc_status_name(&self, status: &DownloadStatus) -> &'static str { + status.as_rpc_status() + } + + /// Builds the effective global option map visible through RPC. + pub(super) fn effective_global_option_map(&self) -> BTreeMap { + option_specs_for_global_view() + .into_iter() + .map(|spec| { + let key = spec + .rpc_names + .first() + .copied() + .unwrap_or(spec.name) + .to_owned(); + let value = self + .engine + .session() + .global_options() + .get(&OptionKey::new(spec.name)) + .map(|value| self.option_value_text(value)) + .unwrap_or_else(|| spec.default_value.to_owned()); + (key, RpcValue::String(value)) + }) + .collect() + } + + /// Builds the effective per-download option map with global fallbacks applied. + pub(super) fn effective_download_option_map( + &self, + group: &RequestGroup, + ) -> BTreeMap { + per_download_option_specs() + .into_iter() + .map(|spec| { + let key = spec + .rpc_names + .first() + .copied() + .unwrap_or(spec.name) + .to_owned(); + let value = group + .options() + .get(&OptionKey::new(spec.name)) + .map(|value| self.option_value_text(value)) + .or_else(|| { + self.engine + .session() + .global_options() + .get(&OptionKey::new(spec.name)) + .map(|value| self.option_value_text(value)) + }) + .unwrap_or_else(|| spec.default_value.to_owned()); + (key, RpcValue::String(value)) + }) + .collect() + } + + /// Converts an internal option value into aria2's stringly RPC representation. + pub(super) fn option_value_text(&self, 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::>() + .join(","), + OptionValue::Empty => String::new(), + } + } + + /// Converts an RPC option object into an engine option patch. + pub(super) fn rpc_object_to_patch(&self, map: BTreeMap) -> OptionPatch { + let mut patch = OptionPatch::new(); + for (key, value) in map { + patch.insert(key, self.rpc_value_to_option_value(value)); + } + patch + } + + /// Converts a single RPC value into the engine option-value model. + pub(super) fn rpc_value_to_option_value(&self, value: RpcValue) -> OptionValue { + match value { + RpcValue::Null => OptionValue::Empty, + RpcValue::Bool(value) => OptionValue::Bool(value), + RpcValue::Number(value) => OptionValue::Int(value), + RpcValue::String(value) => OptionValue::Text(value), + RpcValue::Array(values) => OptionValue::List( + values + .into_iter() + .map(|value| self.option_value_text(&self.rpc_value_to_option_value(value))) + .collect(), + ), + RpcValue::Object(values) => OptionValue::Map( + values + .into_iter() + .map(|(key, value)| { + ( + key, + self.option_value_text(&self.rpc_value_to_option_value(value)), + ) + }) + .collect(), + ), + } + } + + /// Resolves the displayed output path for a download. + pub(super) fn rpc_target_path(&self, group: &RequestGroup) -> String { + let dir = group + .options() + .get(&OptionKey::from("dir")) + .or_else(|| { + self.engine + .session() + .global_options() + .get(&OptionKey::from("dir")) + }) + .and_then(OptionValue::as_text) + .map(std::path::PathBuf::from); + let file_name = group + .options() + .get(&OptionKey::from("out")) + .and_then(OptionValue::as_text) + .map(str::to_owned) + .or_else(|| rpc_uri_file_name(group.uri())) + .unwrap_or_else(|| group.gid().to_string()); + match dir { + Some(dir) => dir.join(file_name).to_string_lossy().into_owned(), + None => file_name, + } + } + + /// Builds aria2 URI entries for a request group. + pub(super) fn rpc_uris_payload(&self, group: &RequestGroup) -> Vec { + if let Some(uri) = group.bt().and_then(|bt| bt.magnet_uri.clone()) { + return vec![RpcValue::Object(BTreeMap::from([ + ("status".to_owned(), RpcValue::String("used".to_owned())), + ("uri".to_owned(), RpcValue::String(uri)), + ]))]; + } + group + .uris() + .iter() + .enumerate() + .map(|(index, uri)| { + RpcValue::Object(BTreeMap::from([ + ( + "status".to_owned(), + RpcValue::String(if index == 0 { "used" } else { "waiting" }.to_owned()), + ), + ("uri".to_owned(), RpcValue::String(uri.clone())), + ])) + }) + .collect() + } + + /// Builds aria2 file payloads for a request group. + pub(super) fn rpc_file_payloads(&self, group: &RequestGroup) -> Vec { + match group.bt() { + Some(bt) if !bt.files.is_empty() => bt + .files + .iter() + .enumerate() + .map(|(index, file)| self.rpc_bt_file_payload(group, index, file)) + .collect(), + _ => vec![self.rpc_file_payload(group)], + } + } + + /// Builds the single-file payload used for non-BitTorrent downloads. + pub(super) fn rpc_file_payload(&self, group: &RequestGroup) -> RpcValue { + let snapshot = self + .engine + .progress_snapshot(group.gid()) + .unwrap_or_else(|_| { + aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), group.status().clone()) + }); + let piece_length = group.piece_length().max(1); + let total_length = snapshot.total_length; + let num_pieces = if total_length == 0 { + 0 + } else { + total_length.div_ceil(piece_length) + }; + let bitfield = self.rpc_piece_bitfield(group, num_pieces); + let path = self.rpc_target_path(group); + let completed_length = + verified_length_for_range(group, 0, total_length, piece_length, total_length); + let mut file = BTreeMap::from([ + ("index".to_owned(), RpcValue::String("1".to_owned())), + ("path".to_owned(), RpcValue::String(path)), + ( + "length".to_owned(), + RpcValue::String(snapshot.total_length.to_string()), + ), + ( + "completedLength".to_owned(), + RpcValue::String(completed_length.to_string()), + ), + ( + "pieceLength".to_owned(), + RpcValue::String(piece_length.to_string()), + ), + ( + "numPieces".to_owned(), + RpcValue::String(num_pieces.to_string()), + ), + ("bitfield".to_owned(), RpcValue::String(bitfield)), + ("selected".to_owned(), RpcValue::String("true".to_owned())), + ( + "uris".to_owned(), + RpcValue::Array(self.rpc_uris_payload(group)), + ), + ]); + file.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group))); + file.insert("btPath".to_owned(), RpcValue::String(String::new())); + file.insert( + "btCompletedPieces".to_owned(), + RpcValue::String( + group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .count() + .to_string(), + ), + ); + RpcValue::Object(file) + } + + /// Builds one aria2 BitTorrent file payload from torrent metadata and progress. + pub(super) fn rpc_bt_file_payload( + &self, + group: &RequestGroup, + index: usize, + file: &BtFileInfo, + ) -> RpcValue { + let piece_length = group.piece_length().max(1); + let file_completed = verified_length_for_range( + group, + file.piece_offset.unwrap_or_default(), + file.length, + piece_length, + group.total_length(), + ); + let num_pieces = if file.length == 0 { + 0 + } else { + file.length.div_ceil(piece_length) + }; + RpcValue::Object(BTreeMap::from([ + ( + "index".to_owned(), + RpcValue::String((index + 1).to_string()), + ), + ("path".to_owned(), RpcValue::String(file.path.clone())), + ( + "length".to_owned(), + RpcValue::String(file.length.to_string()), + ), + ( + "completedLength".to_owned(), + RpcValue::String(file_completed.to_string()), + ), + ( + "pieceLength".to_owned(), + RpcValue::String(piece_length.to_string()), + ), + ( + "numPieces".to_owned(), + RpcValue::String(num_pieces.to_string()), + ), + ( + "bitfield".to_owned(), + RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)), + ), + ( + "selected".to_owned(), + RpcValue::String(file.selected.to_string()), + ), + ( + "uris".to_owned(), + RpcValue::Array(self.rpc_uris_payload(group)), + ), + ("isBt".to_owned(), RpcValue::Bool(true)), + ("btPath".to_owned(), RpcValue::String(file.path.clone())), + ( + "btCompletedPieces".to_owned(), + RpcValue::String( + group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .count() + .to_string(), + ), + ), + ])) + } + + /// Builds the resume-state metadata exposed in dispatcher status payloads. + pub(super) fn rpc_resume_state_payload(&self, group: &RequestGroup) -> RpcValue { + match group.resume_state() { + Some(state) => RpcValue::Object(BTreeMap::from([ + ("persisted".to_owned(), RpcValue::Bool(state.persisted)), + ( + "resumeOffset".to_owned(), + RpcValue::String(state.resume_offset.to_string()), + ), + ( + "validatedLength".to_owned(), + RpcValue::String(state.validated_length.unwrap_or_default().to_string()), + ), + ( + "segmentCursor".to_owned(), + RpcValue::String( + state + .segment_cursor + .map(|piece| piece.0.to_string()) + .unwrap_or_default(), + ), + ), + ])), + None => RpcValue::Null, + } + } + + /// Encodes verified pieces as the hexadecimal bitfield expected by aria2 clients. + pub(super) fn rpc_piece_bitfield(&self, group: &RequestGroup, num_pieces: u64) -> String { + let mut bitfield = String::with_capacity(num_pieces as usize); + for piece_index in 0..num_pieces { + let state = group.piece_state(PieceId(piece_index as u32)); + let marker = match state { + Some(PieceState::Verified) => '2', + Some(PieceState::Downloading) => '1', + _ => '0', + }; + bitfield.push(marker); + } + bitfield + } + + /// Builds a server payload for a non-BitTorrent download. + pub(super) fn rpc_server_payload(&self, group: &RequestGroup) -> RpcValue { + let host = self.rpc_server_host(group.uri()); + let mut top = BTreeMap::from([ + ("index".to_owned(), RpcValue::String("1".to_owned())), + ( + "servers".to_owned(), + RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([ + ("uri".to_owned(), RpcValue::String(group.uri().to_owned())), + ( + "currentUri".to_owned(), + RpcValue::String(group.uri().to_owned()), + ), + ("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())), + ("host".to_owned(), RpcValue::String(host)), + ]))]), + ), + ]); + top.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group))); + RpcValue::Object(top) + } + + /// Builds server or tracker payloads for `aria2.getServers`. + pub(super) fn rpc_server_payloads(&self, group: &RequestGroup) -> Vec { + match group.bt() { + Some(bt) if !bt.trackers.is_empty() => bt + .trackers + .iter() + .enumerate() + .map(|(index, tracker)| self.rpc_bt_server_payload(index, tracker)) + .collect(), + _ => vec![self.rpc_server_payload(group)], + } + } + + /// Builds a tracker row for BitTorrent server payloads. + pub(super) fn rpc_bt_server_payload(&self, index: usize, tracker: &BtTrackerInfo) -> RpcValue { + let host = self.rpc_server_host(&tracker.url); + RpcValue::Object(BTreeMap::from([ + ( + "index".to_owned(), + RpcValue::String((index + 1).to_string()), + ), + ( + "servers".to_owned(), + RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([ + ("uri".to_owned(), RpcValue::String(tracker.url.clone())), + ( + "currentUri".to_owned(), + RpcValue::String(tracker.url.clone()), + ), + ("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())), + ("host".to_owned(), RpcValue::String(host)), + ]))]), + ), + ("isBt".to_owned(), RpcValue::Bool(true)), + ])) + } + + /// Returns whether the request group has BitTorrent runtime metadata. + pub(super) fn rpc_is_bt(&self, group: &RequestGroup) -> bool { + if group.bt().is_some() { + return true; + } + rpc_uri_has_ascii_prefix(group.uri(), "magnet:?") + || rpc_uri_has_ascii_suffix(group.uri(), ".torrent") + } + + /// Builds the BitTorrent-specific portion of an aria2 status payload. + pub(super) fn rpc_bt_status_fields(&self, group: &RequestGroup) -> BTreeMap { + let mut fields = BTreeMap::new(); + let is_bt = self.rpc_is_bt(group); + fields.insert("isBt".to_owned(), RpcValue::Bool(is_bt)); + fields.insert("mode".to_owned(), RpcValue::String("single".to_owned())); + let info_hash = group + .bt() + .map(|bt| bt.info_hash.clone()) + .or_else(|| rpc_bt_info_hash(group.uri())) + .unwrap_or_default(); + fields.insert("infoHash".to_owned(), RpcValue::String(info_hash)); + fields.insert( + "seeder".to_owned(), + RpcValue::String(self.rpc_bt_is_seeder(group).to_string()), + ); + let piece_length = group.piece_length().max(1); + let num_pieces = if group.total_length() == 0 { + 0 + } else { + group.total_length().div_ceil(piece_length) + }; + fields.insert( + "bitfield".to_owned(), + RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)), + ); + fields.insert( + "announceList".to_owned(), + RpcValue::Array(self.rpc_bt_announce_list(group)), + ); + fields.insert("followedBy".to_owned(), RpcValue::Array(Vec::new())); + fields.insert("following".to_owned(), RpcValue::String(String::new())); + fields.insert("belongsTo".to_owned(), RpcValue::String(String::new())); + fields.insert( + "verifiedLength".to_owned(), + RpcValue::String( + (group + .piece_map() + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .count() as u64 + * piece_length) + .to_string(), + ), + ); + fields.insert( + "verifyIntegrityPending".to_owned(), + RpcValue::String("false".to_owned()), + ); + fields.insert( + "metadataOnly".to_owned(), + RpcValue::Bool( + group + .bt() + .map(|bt| bt.metadata_only) + .unwrap_or_else(|| rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")), + ), + ); + fields.insert( + "magnetUri".to_owned(), + RpcValue::String( + group + .bt() + .and_then(|bt| bt.magnet_uri.clone()) + .or_else(|| { + rpc_uri_has_ascii_prefix(group.uri(), "magnet:?") + .then(|| group.uri().to_owned()) + }) + .unwrap_or_default(), + ), + ); + fields.insert( + "creationDate".to_owned(), + RpcValue::String( + group + .bt() + .and_then(|bt| bt.creation_date.clone()) + .unwrap_or_else(|| "0".to_owned()), + ), + ); + fields.insert( + "comment".to_owned(), + RpcValue::String( + group + .bt() + .and_then(|bt| bt.comment.clone()) + .unwrap_or_default(), + ), + ); + fields.insert( + "btFieldCoverage".to_owned(), + RpcValue::Array( + BT_STATUS_FIELDS + .iter() + .map(|name| RpcValue::String((*name).to_owned())) + .collect(), + ), + ); + fields + } + + /// Builds the nested announce-list representation for BitTorrent status payloads. + pub(super) fn rpc_bt_announce_list(&self, group: &RequestGroup) -> Vec { + let Some(bt) = group.bt() else { + return Vec::new(); + }; + let mut tiers = BTreeMap::>::new(); + for tracker in &bt.trackers { + tiers + .entry(tracker.tier.unwrap_or(0)) + .or_default() + .push(tracker.url.clone()); + } + tiers + .into_values() + .map(|tier| RpcValue::Array(tier.into_iter().map(RpcValue::String).collect())) + .collect() + } + + /// Builds peer rows for `aria2.getPeers`. + pub(super) fn rpc_peer_payload(&self, group: &RequestGroup) -> Vec { + if !self.rpc_is_bt(group) { + return Vec::new(); + } + match group.bt() { + Some(bt) => bt + .peers + .iter() + .map(|peer| { + RpcValue::Object(BTreeMap::from([ + ( + "peerId".to_owned(), + RpcValue::String(peer.peer_id.clone().unwrap_or_default()), + ), + ("ip".to_owned(), RpcValue::String(peer.ip.clone())), + ("port".to_owned(), RpcValue::String(peer.port.to_string())), + ("bitfield".to_owned(), RpcValue::String(String::new())), + ( + "amChoking".to_owned(), + RpcValue::String(peer.choked.to_string()), + ), + ( + "peerChoking".to_owned(), + RpcValue::String(peer.choked.to_string()), + ), + ( + "downloadSpeed".to_owned(), + RpcValue::String(peer.download_speed.to_string()), + ), + ( + "uploadSpeed".to_owned(), + RpcValue::String(peer.upload_speed.to_string()), + ), + ( + "seeder".to_owned(), + RpcValue::String(peer.seeder.to_string()), + ), + ])) + }) + .collect(), + None => vec![RpcValue::Object(BTreeMap::from([ + ("peerId".to_owned(), RpcValue::String(String::new())), + ("ip".to_owned(), RpcValue::String(String::new())), + ("port".to_owned(), RpcValue::String("0".to_owned())), + ("bitfield".to_owned(), RpcValue::String(String::new())), + ("amChoking".to_owned(), RpcValue::String("true".to_owned())), + ( + "peerChoking".to_owned(), + RpcValue::String("true".to_owned()), + ), + ("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())), + ("uploadSpeed".to_owned(), RpcValue::String("0".to_owned())), + ("seeder".to_owned(), RpcValue::String("false".to_owned())), + ]))], + } + } + + /// Extracts the host portion displayed in server payloads. + pub(super) fn rpc_server_host(&self, uri: &str) -> String { + uri.split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(uri) + .split('/') + .next() + .unwrap_or_default() + .to_owned() + } + + /// Counts known BitTorrent seeders for a request group. + pub(super) fn rpc_bt_num_seeders(&self, group: &RequestGroup) -> u32 { + group + .bt() + .map(|bt| { + let from_trackers = bt + .trackers + .iter() + .filter_map(|tracker| tracker.seeders) + .max(); + from_trackers + .unwrap_or_else(|| bt.peers.iter().filter(|peer| peer.seeder).count() as u32) + }) + .unwrap_or(0) + } + + /// Returns whether local BitTorrent state should be reported as seeding. + pub(super) fn rpc_bt_is_seeder(&self, group: &RequestGroup) -> bool { + self.engine + .progress_snapshot(group.gid()) + .map(|snapshot| snapshot.bt_true_seeding) + .unwrap_or(false) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/queries.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/queries.rs new file mode 100644 index 0000000..4af503b --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/queries.rs @@ -0,0 +1,270 @@ +use super::{ + BTreeMap, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, + RpcError, RpcValue, filter_status_payload, parse_optional_status_keys, rpc_enabled_features, + slice_handles_by_offset, usize_from_i64, +}; + +impl InProcessRpcDispatcher { + /// Handles `aria2.tellStatus` and optional status-key filtering. + pub(super) fn handle_tell_status(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.tellStatus") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + let keys = match parse_optional_status_keys(request.params.get(1), "aria2.tellStatus") { + Ok(keys) => keys, + Err(message) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message)); + } + }; + match self.engine.registry().get(gid) { + Some(group) => JsonRpcResponse::success( + request.id, + filter_status_payload(self.rpc_status_payload(group), keys.as_ref()), + ), + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("No such download for GID#{gid}")), + ), + } + } + + /// Handles `aria2.tellActive` by returning active download payloads. + pub(super) fn handle_tell_active(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let keys = match parse_optional_status_keys(request.params.first(), "aria2.tellActive") { + Ok(keys) => keys, + Err(message) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message)); + } + }; + let values = self + .engine + .tell_active() + .into_iter() + .filter_map(|handle| self.engine.registry().get(handle.gid())) + .map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref())) + .collect(); + JsonRpcResponse::success(request.id, RpcValue::Array(values)) + } + + /// Handles `aria2.tellWaiting` with aria2-compatible offset and limit semantics. + pub(super) fn handle_tell_waiting(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let (offset, max) = self.parse_offset_and_max(&request); + let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellWaiting") { + Ok(keys) => keys, + Err(message) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message)); + } + }; + let values = slice_handles_by_offset(self.engine.tell_waiting(), offset, max) + .into_iter() + .filter_map(|handle| self.engine.registry().get(handle.gid())) + .map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref())) + .collect(); + JsonRpcResponse::success(request.id, RpcValue::Array(values)) + } + + /// Handles `aria2.tellStopped` with stopped-queue ordering and status filtering. + pub(super) fn handle_tell_stopped(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let (offset, max) = self.parse_offset_and_max(&request); + let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellStopped") { + Ok(keys) => keys, + Err(message) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message)); + } + }; + let values = slice_handles_by_offset(self.engine.tell_stopped(), offset, max) + .into_iter() + .filter_map(|handle| self.engine.registry().get(handle.gid())) + .map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref())) + .collect(); + JsonRpcResponse::success(request.id, RpcValue::Array(values)) + } + + /// Handles global statistics requests using the upstream aria2 response shape. + pub(super) fn handle_tell_global_stat(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let stat = self.engine.get_global_stat(); + JsonRpcResponse::success( + request.id, + RpcValue::Object(BTreeMap::from([ + ( + "downloadSpeed".to_owned(), + RpcValue::String(stat.download_speed.to_string()), + ), + ( + "uploadSpeed".to_owned(), + RpcValue::String(stat.upload_speed.to_string()), + ), + ( + "numActive".to_owned(), + RpcValue::String(stat.num_active.to_string()), + ), + ( + "numWaiting".to_owned(), + RpcValue::String(stat.num_waiting.to_string()), + ), + ( + "numStopped".to_owned(), + RpcValue::String(stat.num_stopped.to_string()), + ), + ( + "numStoppedTotal".to_owned(), + RpcValue::String(self.engine.num_stopped_total().to_string()), + ), + ])), + ) + } + + /// Handles `aria2.getGlobalOption` by exposing effective runtime option values. + pub(super) fn handle_get_global_option(&self, request: JsonRpcRequest) -> JsonRpcResponse { + JsonRpcResponse::success( + request.id, + RpcValue::Object(self.effective_global_option_map()), + ) + } + + /// Handles `aria2.getOption` for a single tracked download. + pub(super) fn handle_get_option(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.getOption") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + match self.engine.registry().get(gid) { + Some(group) => JsonRpcResponse::success( + request.id, + RpcValue::Object(self.effective_download_option_map(group)), + ), + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("Cannot get option for GID#{gid}")), + ), + } + } + + /// Handles `aria2.getSessionInfo` by returning the stable dispatcher session id. + pub(super) fn handle_get_session_info(&self, request: JsonRpcRequest) -> JsonRpcResponse { + JsonRpcResponse::success( + request.id, + RpcValue::Object(BTreeMap::from([( + "sessionId".to_owned(), + RpcValue::String(self.session_id.clone()), + )])), + ) + } + + /// Builds the aria2-compatible version payload shared by JSON-RPC and XML-RPC. + pub(super) fn rpc_version_payload(&self) -> RpcValue { + RpcValue::Object(BTreeMap::from([ + ( + "version".to_owned(), + RpcValue::String(aria2_rust_pro_compat::VERSION.to_owned()), + ), + ( + "enabledFeatures".to_owned(), + RpcValue::Array( + rpc_enabled_features() + .into_iter() + .map(|feature| RpcValue::String((*feature).to_owned())) + .collect(), + ), + ), + ])) + } + + /// Handles `aria2.getUris` by projecting stored source URIs for a download. + pub(super) fn handle_get_uris(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.getUris") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + match self.engine.registry().get(gid) { + Some(group) => { + JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_uris_payload(group))) + } + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("No URI data is available for GID#{gid}")), + ), + } + } + + /// Handles `aria2.getFiles` by returning per-file progress payloads. + pub(super) fn handle_get_files(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.getFiles") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + match self.engine.registry().get(gid) { + Some(group) => { + JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_file_payloads(group))) + } + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("No file data is available for GID#{gid}")), + ), + } + } + + /// Handles `aria2.getServers` by exposing active server or tracker rows. + pub(super) fn handle_get_servers(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.getServers") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + match self.engine.registry().get(gid) { + Some(group) if group.status() == &DownloadStatus::Active => JsonRpcResponse::success( + request.id, + RpcValue::Array(self.rpc_server_payloads(group)), + ), + _ => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("No active download for GID#{gid}")), + ), + } + } + + /// Handles `aria2.getPeers` by returning BitTorrent peer rows for BT downloads. + pub(super) fn handle_get_peers(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let gid = match self.parse_gid_from_first_param(&request, "aria2.getPeers") { + Ok(gid) => gid, + Err(error) => return JsonRpcResponse::error(request.id, error), + }; + match self.engine.registry().get(gid) { + Some(group) => { + JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_peer_payload(group))) + } + None => JsonRpcResponse::error( + request.id, + RpcError::unsupported(&format!("No peer data is available for GID#{gid}")), + ), + } + } + + /// Parses the first RPC parameter as a download id and maps errors to RPC failures. + pub(super) fn parse_gid_from_first_param( + &self, + request: &JsonRpcRequest, + method: &'static str, + ) -> Result { + let Some(RpcValue::String(gid)) = request.params.first() else { + return Err(RpcError::invalid_params(&format!("{method} needs gid"))); + }; + DownloadId::parse_hex(gid) + .ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}"))) + } + + /// Parses optional queue pagination parameters using aria2 defaults. + pub(super) fn parse_offset_and_max(&self, request: &JsonRpcRequest) -> (i64, usize) { + let offset = match request.params.first() { + Some(RpcValue::Number(value)) => *value, + _ => 0, + }; + let max = match request.params.get(1) { + Some(RpcValue::Number(value)) if *value >= 0 => { + usize_from_i64(*value).unwrap_or(usize::MAX) + } + _ => usize::MAX, + }; + (offset, max) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests.rs new file mode 100644 index 0000000..503a9b3 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests.rs @@ -0,0 +1,678 @@ +use std::{ + collections::BTreeMap, + fs, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, +}; + +use aria2_rust_pro_core::{ + BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, DownloadId, PieceId, + PieceState, RuntimeConfig, +}; +use aria2_rust_pro_protocol::{ + DhtMessageModel, DhtNodeModel, DhtTransport, TrackerRequestModel, TrackerResponseModel, + TrackerScrapeModel, TrackerTransport, + torrent::{ + DhtGetPeersQueryModel, DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, + PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind, + PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel, + TorrentMessageModel, parse_torrent_metadata, + }, + transport::{ + PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, + TransportEndpoint, TransportError, TransportErrorKind, TransportScheme, + }, +}; +use aria2_rust_pro_storage::load_session_file; +use base64::Engine; + +use super::{ + BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepStatus, InProcessRpcDispatcher, + bt_metadata_piece_span, decode_hex_string_exact, +}; +use crate::{ + jsonrpc::{JsonRpcRequest, jsonrpc_request_from_json, jsonrpc_response_to_json}, + methods::RpcMethod, + model::{RpcError, RpcMeta, RpcValue}, + xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcParam, XmlRpcValue}, +}; + +#[doc(hidden)] +fn temp_session_path(name: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be monotonic enough for test naming") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "aria2-rust-pro-rpc-test-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temp dir should be creatable"); + root.join(name) +} + +#[doc(hidden)] +fn request(method: RpcMethod, params: Vec) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.as_str().to_owned(), + params, + meta: RpcMeta::default(), + } +} + +#[doc(hidden)] +fn request_with_method_name(method: &str, params: Vec) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.to_owned(), + params, + meta: RpcMeta::default(), + } +} + +#[doc(hidden)] +fn add_uri(dispatcher: &mut InProcessRpcDispatcher, uri: &str) -> String { + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(uri.to_owned())], + )); + match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + } +} + +#[doc(hidden)] +#[test] +fn add_uri_direct_registers_uri_and_options_without_jsonrpc_roundtrip() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = dispatcher + .add_uri_direct( + vec![ + "https://example.org/direct-a.iso".to_owned(), + "https://example.org/direct-b.iso".to_owned(), + ], + vec![("split".to_owned(), RpcValue::String("8".to_owned()))], + ) + .expect("direct addUri should register"); + let group = dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("direct addUri group should exist"); + + assert_eq!(group.uri(), "https://example.org/direct-a.iso"); + assert_eq!( + group.uris(), + &[ + "https://example.org/direct-a.iso".to_owned(), + "https://example.org/direct-b.iso".to_owned(), + ] + ); + assert_eq!(group.option_limit("split"), Some(8)); +} + +#[doc(hidden)] +fn download_id(gid: &str) -> DownloadId { + DownloadId::parse_hex(gid).expect("gid should parse into DownloadId") +} + +#[doc(hidden)] +fn compact_peer(ip: [u8; 4], port: u16) -> Vec { + let mut bytes = Vec::with_capacity(6); + bytes.extend_from_slice(&ip); + bytes.extend_from_slice(&port.to_be_bytes()); + bytes +} + +#[doc(hidden)] +fn compact_node(node_id_byte: u8, ip: [u8; 4], port: u16) -> Vec { + let mut bytes = vec![node_id_byte; 20]; + bytes.extend_from_slice(&ip); + bytes.extend_from_slice(&port.to_be_bytes()); + bytes +} + +#[doc(hidden)] +#[derive(Debug)] +struct FakeDhtTransport { + #[doc(hidden)] + response: DhtMessageModel, + #[doc(hidden)] + seen: Mutex>, +} + +impl FakeDhtTransport { + #[doc(hidden)] + fn new(response: DhtMessageModel) -> Self { + Self { + response, + seen: Mutex::new(Vec::new()), + } + } + + #[doc(hidden)] + fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> { + self.seen + .lock() + .expect("seen requests mutex should not be poisoned") + .clone() + } +} + +impl DhtTransport for FakeDhtTransport { + #[doc(hidden)] + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + self.seen + .lock() + .expect("seen requests mutex should not be poisoned") + .push((node.clone(), message.clone())); + Ok(self.response.clone()) + } +} + +#[doc(hidden)] +#[derive(Debug)] +struct FakeTrackerTransport { + #[doc(hidden)] + announce_response: TrackerResponseModel, + #[doc(hidden)] + scrape_response: Option, + #[doc(hidden)] + seen_announces: Mutex>, + #[doc(hidden)] + seen_scrapes: Mutex>, +} + +impl FakeTrackerTransport { + #[doc(hidden)] + fn new( + announce_response: TrackerResponseModel, + scrape_response: Option, + ) -> Self { + Self { + announce_response, + scrape_response, + seen_announces: Mutex::new(Vec::new()), + seen_scrapes: Mutex::new(Vec::new()), + } + } + + #[doc(hidden)] + fn seen_announces(&self) -> Vec { + self.seen_announces + .lock() + .expect("tracker announce mutex should not be poisoned") + .clone() + } + + #[doc(hidden)] + fn seen_scrapes(&self) -> Vec { + self.seen_scrapes + .lock() + .expect("tracker scrape mutex should not be poisoned") + .clone() + } +} + +impl TrackerTransport for FakeTrackerTransport { + #[doc(hidden)] + fn announce( + &self, + request: &TrackerRequestModel, + ) -> Result { + self.seen_announces + .lock() + .expect("tracker announce mutex should not be poisoned") + .push(request.clone()); + Ok(self.announce_response.clone()) + } + + #[doc(hidden)] + fn scrape(&self, url: &str) -> Result { + self.seen_scrapes + .lock() + .expect("tracker scrape mutex should not be poisoned") + .push(url.to_owned()); + self.scrape_response.clone().ok_or_else(|| TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: "scrape unavailable".to_owned(), + source: None, + context: None, + }) + } +} + +#[doc(hidden)] +#[derive(Debug)] +struct FakePeerWireConnector { + #[doc(hidden)] + response_payload: Vec, + #[doc(hidden)] + seen: Mutex>, +} + +impl FakePeerWireConnector { + #[doc(hidden)] + fn new(response_payload: Vec) -> Self { + Self { + response_payload, + seen: Mutex::new(Vec::new()), + } + } + + #[doc(hidden)] + fn seen(&self) -> Vec { + self.seen + .lock() + .expect("peer-wire seen requests mutex should not be poisoned") + .clone() + } +} + +#[doc(hidden)] +#[derive(Debug)] +struct SequencedPeerWireConnector { + #[doc(hidden)] + response_payloads: Mutex>>, + #[doc(hidden)] + seen: Mutex>, +} + +impl SequencedPeerWireConnector { + #[doc(hidden)] + fn new(response_payloads: Vec>) -> Self { + Self { + response_payloads: Mutex::new(response_payloads), + seen: Mutex::new(Vec::new()), + } + } + + #[doc(hidden)] + fn seen(&self) -> Vec { + self.seen + .lock() + .expect("sequenced peer-wire seen requests mutex should not be poisoned") + .clone() + } +} + +impl PeerWireTransportConnector for SequencedPeerWireConnector { + #[doc(hidden)] + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + self.seen + .lock() + .expect("sequenced peer-wire seen requests mutex should not be poisoned") + .push(request.clone()); + let payload = self + .response_payloads + .lock() + .expect("sequenced peer-wire payload mutex should not be poisoned") + .remove(0); + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: request.endpoint.address.clone(), + }, + payload, + }) + } +} + +impl PeerWireTransportConnector for FakePeerWireConnector { + #[doc(hidden)] + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + self.seen + .lock() + .expect("peer-wire seen requests mutex should not be poisoned") + .push(request.clone()); + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: request.endpoint.address.clone(), + }, + payload: self.response_payload.clone(), + }) + } +} + +#[doc(hidden)] +#[derive(Debug)] +struct RoutedDhtTransport { + #[doc(hidden)] + get_peers_response: DhtMessageModel, + #[doc(hidden)] + announce_peer_response: DhtMessageModel, + #[doc(hidden)] + find_node_response: Option, + #[doc(hidden)] + ping_response: Option, + #[doc(hidden)] + seen: Mutex>, +} + +impl RoutedDhtTransport { + #[doc(hidden)] + fn new(get_peers_response: DhtMessageModel, announce_peer_response: DhtMessageModel) -> Self { + Self { + get_peers_response, + announce_peer_response, + find_node_response: None, + ping_response: None, + seen: Mutex::new(Vec::new()), + } + } + + #[doc(hidden)] + fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> { + self.seen + .lock() + .expect("routed dht seen mutex should not be poisoned") + .clone() + } +} + +impl DhtTransport for RoutedDhtTransport { + #[doc(hidden)] + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + self.seen + .lock() + .expect("routed dht seen mutex should not be poisoned") + .push((node.clone(), message.clone())); + match &message.body { + DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => { + Ok(self.get_peers_response.clone()) + } + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => { + Ok(self.announce_peer_response.clone()) + } + DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => self + .find_node_response + .clone() + .ok_or_else(|| TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: "find_node unavailable".to_owned(), + source: None, + context: None, + }), + DhtMessageBody::Query(DhtQueryModel::Ping(_)) => { + self.ping_response.clone().ok_or_else(|| TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: "ping unavailable".to_owned(), + source: None, + context: None, + }) + } + _ => Err(TransportError { + kind: TransportErrorKind::ProtocolViolation, + message: "unexpected dht method for routed transport".to_owned(), + source: None, + context: None, + }), + } + } +} + +#[doc(hidden)] +fn peer_wire_handshake_and_frames( + info_hash: [u8; 20], + peer_id: [u8; 20], + frames: &[PeerWireMessageKind], +) -> Vec { + let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize(); + for frame in frames { + bytes.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(frame.clone()) + .serialize_peer_wire_frame() + .expect("peer-wire frame should serialize"), + ); + } + bytes +} + +#[doc(hidden)] +fn peer_from_ip(ip: &str, port: u16) -> BtPeerInfo { + BtPeerInfo { + peer_id: None, + ip: ip.to_owned(), + port, + client_name: None, + interested: false, + choked: true, + download_speed: 0, + upload_speed: 0, + seeder: false, + } +} + +#[doc(hidden)] +fn single_file_torrent_bytes(name: &str, comment_len: usize) -> Vec { + let comment = "x".repeat(comment_len); + format!( + "d8:announce35:http://tracker.example.org/announce7:comment{}:{}4:infod6:lengthi2048e4:name{}:{}12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee", + comment.len(), + comment, + name.len(), + name + ) + .into_bytes() +} + +#[doc(hidden)] +#[test] +fn bt_runtime_coordinator_snapshot_surfaces_partial_magnet_runtime_and_recommended_actions() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download group should exist"); + group.set_piece_length(1_024); + group.set_total_length(4_096); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + group.set_dht_token(Some(b"cached-token".to_vec())); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.9:6881".to_owned()]; + bt.peers = vec![peer_from_ip("127.0.0.7", 51413)]; + } + + let snapshot = dispatcher + .bt_runtime_coordinator_snapshot(&gid) + .expect("snapshot should inspect bt runtime"); + + assert!(snapshot.metadata_only); + assert!(snapshot.metadata_exchange_pending); + assert_eq!(snapshot.tracker_count, 1); + assert_eq!(snapshot.dht_node_count, 2); + assert_eq!(snapshot.addressable_dht_node_count, 1); + assert_eq!(snapshot.peer_count, 1); + assert_eq!(snapshot.connectable_peer_count, 1); + assert_eq!(snapshot.requestable_piece_count, 2); + assert_eq!( + snapshot.recommended_actions, + vec![ + BtRuntimeCoordinatorAction::TrackerAnnounce, + BtRuntimeCoordinatorAction::DhtGetPeers, + BtRuntimeCoordinatorAction::DhtAnnouncePeer, + BtRuntimeCoordinatorAction::PeerWireExchange, + ] + ); +} + +#[doc(hidden)] +#[test] +fn drive_bt_runtime_once_executes_newly_unlocked_bt_steps_within_one_iteration() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download group should exist"); + group.set_piece_length(1_024); + group.set_total_length(2_048); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["127.0.0.11:6881".to_owned()]; + } + + let tracker = FakeTrackerTransport::new( + TrackerResponseModel { + peers: aria2_rust_pro_protocol::TrackerPeerListModel { + interval_sec: 1_800, + peers: vec![aria2_rust_pro_protocol::torrent::TorrentPeerModel { + ip: "127.0.0.21".to_owned(), + port: 51_413, + peer_id: Some(*b"12345678901234567890"), + client_name: Some("tracker-peer".to_owned()), + interested: false, + choked: false, + }], + min_interval_sec: None, + tracker_id: Some("tracker-id".to_owned()), + }, + scrape: None, + }, + Some(TrackerScrapeModel { + complete: Some(5), + downloaded: Some(8), + incomplete: Some(3), + files: Vec::new(), + }), + ); + let dht = RoutedDhtTransport::new( + DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x99; 20], + Some(b"announce-token".to_vec()), + Some(compact_node(0x77, [127, 0, 0, 31], 6882)), + Vec::new(), + ), + DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x55; 20]), + ); + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + [ + 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, + ], + *b"-PC0001-LOOP-PEER-01", + &[PeerWireMessageKind::Unchoke], + )); + + let report = dispatcher + .drive_bt_runtime_once( + &gid, + Some(&tracker), + Some(&dht), + Some(&connector), + Some(1_050), + ) + .expect("coordinator loop should run"); + + assert!(report.initial_snapshot.metadata_exchange_pending); + assert!(report.final_snapshot.metadata_exchange_pending); + assert_eq!( + report + .steps + .iter() + .map(|step| (step.action, step.status)) + .collect::>(), + vec![ + ( + BtRuntimeCoordinatorAction::AdvanceClock, + BtRuntimeCoordinatorStepStatus::Executed, + ), + ( + BtRuntimeCoordinatorAction::TrackerAnnounce, + BtRuntimeCoordinatorStepStatus::Executed, + ), + ( + BtRuntimeCoordinatorAction::DhtGetPeers, + BtRuntimeCoordinatorStepStatus::Executed, + ), + ( + BtRuntimeCoordinatorAction::DhtAnnouncePeer, + BtRuntimeCoordinatorStepStatus::Executed, + ), + ( + BtRuntimeCoordinatorAction::PeerWireExchange, + BtRuntimeCoordinatorStepStatus::Executed, + ), + ] + ); + + assert_eq!(tracker.seen_announces().len(), 1); + assert_eq!( + tracker.seen_scrapes(), + vec!["http://tracker.example.org/announce".to_owned()] + ); + assert_eq!( + dht.seen().len(), + 2, + "get_peers plus announce_peer should run" + ); + assert_eq!( + connector.seen().len(), + 1, + "peer-wire should run after peers arrive" + ); + + let final_snapshot = dispatcher + .bt_runtime_coordinator_snapshot(&gid) + .expect("final snapshot should remain readable"); + assert!(final_snapshot.has_dht_token); + assert!(final_snapshot.connectable_peer_count >= 1); + assert!(final_snapshot.addressable_dht_node_count >= 2); +} + +#[doc(hidden)] +fn xml_request(method_name: &str) -> XmlRpcMethodCall { + XmlRpcMethodCall { + method_name: method_name.to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + } +} + +#[doc(hidden)] +fn xml_request_with_params(method_name: &str, params: Vec) -> XmlRpcMethodCall { + XmlRpcMethodCall { + method_name: method_name.to_owned(), + params: params + .into_iter() + .map(|value| XmlRpcParam { value }) + .collect(), + meta: RpcMeta::default(), + } +} + +mod bt_and_extension; +mod protocol_surface; +mod queue_and_options; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension.rs new file mode 100644 index 0000000..2449ee4 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension.rs @@ -0,0 +1,7 @@ +pub(super) use super::*; + +mod bt_status_and_magnet; +mod dht_runtime; +mod extensions_and_multicall; +mod peer_wire_runtime; +mod tracker_and_bridges; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/bt_status_and_magnet.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/bt_status_and_magnet.rs new file mode 100644 index 0000000..2c1476b --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/bt_status_and_magnet.rs @@ -0,0 +1,336 @@ +use super::*; + +#[test] +fn bt_status_reports_local_seeding_truthfully_under_peer_pressure() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=SeedFields", + ); + let download_id = DownloadId::parse_hex(&gid).expect("gid should parse"); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should exist"); + group.set_status(aria2_rust_pro_core::DownloadStatus::Active); + group.set_total_length(10_000); + group.set_completed_length(4_000); + group.set_upload_length(2_500); + let mut bt = group + .bt() + .cloned() + .expect("magnet should have bt runtime state"); + bt.peers.push(BtPeerInfo { + peer_id: Some("feedbeef".to_owned()), + ip: "10.0.0.2".to_owned(), + port: 51413, + client_name: Some("seed-peer".to_owned()), + interested: true, + choked: false, + download_speed: 0, + upload_speed: 128, + seeder: true, + }); + group.set_bt(bt); + group + .options_mut() + .insert("seed-time", aria2_rust_pro_core::OptionValue::UInt(600)); + } + dispatcher + .engine + .set_bt_seeding_state(download_id, true, Some(1_000)) + .expect("local seeding should start"); + dispatcher + .engine + .set_bt_seeding_state(download_id, false, Some(1_030)) + .expect("local seeding should stop"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("active".to_owned())) + ); + assert_ne!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + assert_eq!( + payload.get("seeder"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("uploadLength"), + Some(&RpcValue::String("2500".to_owned())) + ); + assert!(matches!( + payload.get("shareRatio"), + Some(RpcValue::String(value)) if !value.is_empty() + )); + assert_eq!( + payload.get("shareTime"), + Some(&RpcValue::String("30".to_owned())) + ); + } + other => panic!("unexpected tellStatus seeding payload: {other:?}"), + } +} + +#[test] +fn get_servers_and_peers_include_bt_seed_state_fields() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_status(aria2_rust_pro_core::DownloadStatus::Active); + let mut bt = group + .bt() + .cloned() + .expect("magnet should have bt runtime state"); + bt.trackers[0].seeders = Some(9); + bt.peers.push(BtPeerInfo { + peer_id: Some("001122".to_owned()), + ip: "127.0.0.1".to_owned(), + port: 6881, + client_name: Some("peer-a".to_owned()), + interested: true, + choked: false, + download_speed: 16, + upload_speed: 32, + seeder: true, + }); + group.set_bt(bt); + + let servers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + match servers.result { + Some(RpcValue::Array(entries)) => { + assert!(!entries.is_empty()); + assert!(matches!( + entries.first(), + Some(RpcValue::Object(server)) if server.get("isBt") == Some(&RpcValue::Bool(true)) + )); + } + other => panic!("unexpected getServers seed-state result: {other:?}"), + } + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("uploadSpeed"), + Some(&RpcValue::String("32".to_owned())) + ); + assert_eq!( + peer.get("seeder"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected getPeers seed-state row: {other:?}"), + }, + other => panic!("unexpected getPeers seed-state result: {other:?}"), + } +} + +#[test] +fn get_peers_returns_bt_peer_shape_for_bt_like_download() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + ); + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => { + assert!( + items.is_empty(), + "magnet registration alone should not fabricate peer rows" + ); + } + other => panic!("unexpected getPeers result: {other:?}"), + } +} + +#[test] +fn add_uri_magnet_registers_runtime_backed_bt_fields() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!( + payload.get("metadataOnly"), + Some(&RpcValue::Bool(true)), + "magnet registrations should be metadata-only at addUri time" + ); + assert_eq!( + payload.get("infoHash"), + Some(&RpcValue::String( + "0123456789ABCDEF0123456789ABCDEF01234567".to_owned() + )) + ); + assert!(matches!( + payload.get("magnetUri"), + Some(RpcValue::String(uri)) if uri.starts_with("magnet:?") + )); + assert!(matches!( + payload.get("announceList"), + Some(RpcValue::Array(tiers)) + if matches!( + tiers.first(), + Some(RpcValue::Array(urls)) + if urls.contains(&RpcValue::String( + "http://tracker.example.org/announce".to_owned() + )) + ) + )); + } + other => panic!("unexpected tellStatus after magnet addUri: {other:?}"), + } +} + +#[test] +fn add_uri_magnet_uses_bootstrap_peer_and_dht_hints() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=peer-hints&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&x.pe=198.51.100.9:51413&x.pe=%5B2001:db8::9%5D:51413", + ); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => { + assert_eq!(items.len(), 2); + let rendered = items + .iter() + .map(|value| match value { + RpcValue::Object(payload) => { + (payload.get("ip").cloned(), payload.get("port").cloned()) + } + other => panic!("unexpected peer row: {other:?}"), + }) + .collect::>(); + assert!(rendered.contains(&( + Some(RpcValue::String("198.51.100.9".to_owned())), + Some(RpcValue::String("51413".to_owned())), + ))); + assert!(rendered.contains(&( + Some(RpcValue::String("2001:db8::9".to_owned())), + Some(RpcValue::String("51413".to_owned())), + ))); + } + other => panic!("unexpected getPeers result for hinted magnet: {other:?}"), + } + + let group = dispatcher + .engine + .registry() + .get(download_id(&gid)) + .expect("magnet gid should remain registered"); + let bt = group.bt().expect("magnet gid should own bt state"); + assert!( + bt.dht_nodes.contains(&"198.51.100.9:51413".to_owned()), + "ipv4 x.pe hint should seed dht/bootstrap nodes" + ); + assert!( + bt.dht_nodes.contains(&"[2001:db8::9]:51413".to_owned()), + "ipv6 x.pe hint should seed dht/bootstrap nodes" + ); + assert_eq!(bt.info_hash.len(), 40); + assert!( + bt.info_hash + .chars() + .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_lowercase()), + "base32 btih should normalize into canonical uppercase hex" + ); +} + +#[test] +fn apply_tracker_announce_result_updates_get_peers() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let announce = TrackerResponseModel { + peers: aria2_rust_pro_protocol::TrackerPeerListModel { + interval_sec: 1800, + peers: vec![aria2_rust_pro_protocol::TorrentPeerModel { + peer_id: Some(*b"12345678901234567890"), + ip: "127.0.0.1".to_owned(), + port: 6881, + client_name: Some("rust-peer".to_owned()), + interested: true, + choked: false, + }], + min_interval_sec: Some(900), + tracker_id: Some("tracker-session-id".to_owned()), + }, + scrape: Some(TrackerScrapeModel { + complete: Some(12), + downloaded: Some(34), + incomplete: Some(56), + files: Vec::new(), + }), + }; + dispatcher + .apply_tracker_announce_result(&gid, &announce) + .expect("tracker announce should ingest into runtime state"); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("127.0.0.1".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); + assert_eq!( + peer.get("peerId"), + Some(&RpcValue::String( + "3132333435363738393031323334353637383930".to_owned() + )) + ); + } + other => panic!("unexpected getPeers entry after tracker ingest: {other:?}"), + }, + other => panic!("unexpected getPeers result after tracker ingest: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/dht_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/dht_runtime.rs new file mode 100644 index 0000000..ee70c13 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/dht_runtime.rs @@ -0,0 +1,514 @@ +use super::*; + +#[test] +fn execute_dht_get_peers_rejects_missing_or_invalid_runtime_nodes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + ); + let response = + DhtMessageModel::get_peers_response(b"gp".to_vec(), vec![0x11; 20], None, None, Vec::new()); + let transport = FakeDhtTransport::new(response); + let download_id = download_id(&gid); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes.clear(); + } + let missing_nodes = dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect_err("missing nodes should be rejected"); + assert!( + missing_nodes.message.contains("at least one dht node"), + "unexpected missing-nodes error: {}", + missing_nodes.message + ); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["not-a-node".to_owned(), "still.bad:99999".to_owned()]; + } + let invalid_nodes = dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect_err("invalid node list should be rejected"); + assert!( + invalid_nodes.message.contains("no valid dht nodes"), + "unexpected invalid-node error: {}", + invalid_nodes.message + ); + assert!(transport.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn execute_dht_ping_rejects_missing_or_invalid_runtime_nodes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:abababababababababababababababababababab", + ); + let response = DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x11; 20]); + let transport = FakeDhtTransport::new(response); + let download_id = download_id(&gid); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes.clear(); + } + let missing_nodes = dispatcher + .execute_dht_ping(&gid, &transport) + .expect_err("missing nodes should be rejected"); + assert!( + missing_nodes.message.contains("at least one dht node"), + "unexpected missing-nodes error: {}", + missing_nodes.message + ); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["bad-node".to_owned(), "still.bad:99999".to_owned()]; + } + let invalid_nodes = dispatcher + .execute_dht_ping(&gid, &transport) + .expect_err("invalid nodes should be rejected"); + assert!( + invalid_nodes.message.contains("no valid dht nodes"), + "unexpected invalid-node error: {}", + invalid_nodes.message + ); + assert!(transport.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn apply_dht_ping_result_promotes_responsive_node_and_rejects_bad_node_id() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec![ + "198.51.100.7:6881".to_owned(), + "203.0.113.8:6882".to_owned(), + ]; + } + + dispatcher + .apply_dht_ping_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.8".to_owned(), + port: 6882, + }, + &DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x44; 20]), + ) + .expect("valid ping should promote responsive node"); + + let bt = dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain present"); + assert_eq!( + bt.dht_nodes.first().map(String::as_str), + Some("203.0.113.8:6882") + ); + + let error = dispatcher + .apply_dht_ping_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "192.0.2.9".to_owned(), + port: 6881, + }, + &DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x55; 19]), + ) + .expect_err("short node id should be rejected"); + assert!(error.message.contains("node id must be 20 bytes")); +} + +#[test] +fn apply_dht_get_peers_result_updates_rpc_visible_bt_peers() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff", + ); + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x21; 20], + Some(b"tok".to_vec()), + Some(compact_node(0x44, [127, 0, 0, 2], 6882)), + vec![compact_peer([127, 0, 0, 1], 6881)], + ); + + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "127.0.0.9".to_owned(), + port: 7001, + }, + &response, + ) + .expect("dht apply should ingest peers"); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("127.0.0.1".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); + } + other => panic!("unexpected getPeers entry after dht apply: {other:?}"), + }, + other => panic!("unexpected getPeers result after dht apply: {other:?}"), + } + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + } + other => panic!("unexpected tellStatus after dht apply: {other:?}"), + } + + let bt = dispatcher + .engine + .registry() + .get(download_id(&gid)) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain available"); + assert!(bt.dht_nodes.contains(&"127.0.0.9:7001".to_owned())); + assert!(bt.dht_nodes.contains(&"127.0.0.2:6882".to_owned())); +} + +#[test] +fn execute_dht_get_peers_updates_bt_views_and_retains_discovered_nodes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=ubuntu", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["bad-node-entry".to_owned(), "127.0.0.8:6885".to_owned()]; + } + + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x31; 20], + Some(b"node-token".to_vec()), + Some(compact_node(0x55, [127, 0, 0, 7], 6890)), + vec![compact_peer([127, 0, 0, 6], 6884)], + ); + let transport = FakeDhtTransport::new(response); + + dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect("dht get_peers should succeed"); + + let seen = transport.seen(); + assert_eq!(seen.len(), 1, "transport should see exactly one request"); + assert_eq!(seen[0].0.address, "127.0.0.8"); + assert_eq!(seen[0].0.port, 6885); + match &seen[0].1.body { + DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel { + info_hash, .. + })) => assert_eq!( + info_hash, + &vec![ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67 + ] + ), + other => panic!("unexpected dht request body: {other:?}"), + } + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("127.0.0.6".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6884".to_owned()))); + } + other => panic!("unexpected getPeers entry after dht execute: {other:?}"), + }, + other => panic!("unexpected getPeers result after dht execute: {other:?}"), + } + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + } + other => panic!("unexpected tellStatus after dht execute: {other:?}"), + } + + let bt = dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain available"); + assert!(bt.dht_nodes.contains(&"127.0.0.8:6885".to_owned())); + assert!(bt.dht_nodes.contains(&"127.0.0.7:6890".to_owned())); +} + +#[test] +fn execute_dht_ping_sends_ping_query_and_promotes_responsive_node() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.10:6886".to_owned()]; + } + + let response = DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x77; 20]); + let transport = FakeDhtTransport::new(response); + + dispatcher + .execute_dht_ping(&gid, &transport) + .expect("dht ping should succeed"); + + let seen = transport.seen(); + assert_eq!(seen.len(), 1, "transport should see exactly one ping"); + assert_eq!(seen[0].0.address, "127.0.0.10"); + assert_eq!(seen[0].0.port, 6886); + match &seen[0].1.body { + DhtMessageBody::Query(DhtQueryModel::Ping(query)) => { + assert_eq!(query.node_id.len(), 20); + } + other => panic!("unexpected dht ping request body: {other:?}"), + } + + let bt = dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain available"); + assert_eq!( + bt.dht_nodes.first().map(String::as_str), + Some("127.0.0.10:6886") + ); +} + +#[test] +fn execute_dht_find_node_rejects_missing_or_invalid_runtime_nodes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:dededededededededededededededededededede", + ); + let response = DhtMessageModel::find_node_response(b"fn".to_vec(), vec![0x11; 20], Vec::new()); + let transport = FakeDhtTransport::new(response); + let download_id = download_id(&gid); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes.clear(); + } + let missing_nodes = dispatcher + .execute_dht_find_node(&gid, &transport) + .expect_err("missing nodes should be rejected"); + assert!( + missing_nodes.message.contains("at least one dht node"), + "unexpected missing-nodes error: {}", + missing_nodes.message + ); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec!["bad-node".to_owned(), "still.bad:99999".to_owned()]; + } + let invalid_nodes = dispatcher + .execute_dht_find_node(&gid, &transport) + .expect_err("invalid nodes should be rejected"); + assert!( + invalid_nodes.message.contains("no valid dht nodes"), + "unexpected invalid-node error: {}", + invalid_nodes.message + ); + assert!(transport.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn execute_dht_get_peers_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-dht-gid".to_owned(); + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x31; 20], + Some(b"node-token".to_vec()), + None, + Vec::new(), + ); + let transport = FakeDhtTransport::new(response); + + let error = dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect_err("invalid gid should be rejected before transport"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); + assert!(transport.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn execute_dht_get_peers_reports_missing_download_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "00000000000000ab".to_owned(); + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x31; 20], + Some(b"node-token".to_vec()), + None, + Vec::new(), + ); + let transport = FakeDhtTransport::new(response); + + let error = dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect_err("missing gid should be rejected before transport"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("No such download for GID#{gid}")); + assert!(transport.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn apply_dht_find_node_result_discovers_nodes_and_promotes_responsive_node() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:efefefefefefefefefefefefefefefefefefefef", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.dht_nodes = vec![ + "198.51.100.7:6881".to_owned(), + "203.0.113.8:6882".to_owned(), + ]; + } + + dispatcher + .apply_dht_find_node_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.8".to_owned(), + port: 6882, + }, + &DhtMessageModel::find_node_response( + b"fn".to_vec(), + vec![0x44; 20], + vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel { + node_id: [0x88; 20], + address: [127, 0, 0, 7], + port: 6890, + }], + ), + ) + .expect("valid find_node response should promote responsive node"); + + let bt = dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain present"); + assert_eq!( + bt.dht_nodes.first().map(String::as_str), + Some("203.0.113.8:6882") + ); + assert!(bt.dht_nodes.contains(&"127.0.0.7:6890".to_owned())); + + let error = dispatcher + .apply_dht_find_node_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "192.0.2.9".to_owned(), + port: 6881, + }, + &DhtMessageModel::find_node_response(b"fn".to_vec(), vec![0x55; 19], Vec::new()), + ) + .expect_err("short node id should be rejected"); + assert!(error.message.contains("node id must be 20 bytes")); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/extensions_and_multicall.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/extensions_and_multicall.rs new file mode 100644 index 0000000..8d15802 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/extensions_and_multicall.rs @@ -0,0 +1,338 @@ +use super::*; + +#[test] +fn add_metalink_registers_preferred_resource_uri_as_download() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddMetalink, + vec![RpcValue::String( + r#" + + +http://example.org/example.iso +http://mirror.example.org/example.iso + +"# + .to_owned(), + )], + )); + + let gid = match response.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }, + other => panic!("unexpected addMetalink result: {other:?}"), + }; + assert_eq!(dispatcher.tracked_download_count(), 1); + + let uris = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid.clone())], + )); + match uris.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(entry)) => { + assert_eq!( + entry.get("uri"), + Some(&RpcValue::String( + "http://mirror.example.org/example.iso".to_owned() + )) + ); + } + other => panic!("unexpected getUris entry after addMetalink: {other:?}"), + }, + other => panic!("unexpected getUris result after addMetalink: {other:?}"), + } +} + +#[test] +fn add_metalink_accepts_base64_payload_and_applies_options() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let xml = r#" + + +http://example.org/example.iso +http://mirror.example.org/example.iso + +"#; + let payload = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes()); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddMetalink, + vec![ + RpcValue::String(payload), + RpcValue::Object(BTreeMap::from([( + "dir".to_owned(), + RpcValue::String("/metalink-downloads".to_owned()), + )])), + RpcValue::Number(0), + ], + )); + + let gid = match response.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }, + other => panic!("unexpected addMetalink result: {other:?}"), + }; + let options = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + match options.result { + Some(RpcValue::Object(options)) => { + assert_eq!( + options.get("dir"), + Some(&RpcValue::String("/metalink-downloads".to_owned())) + ); + } + other => panic!("unexpected getOption result after addMetalink: {other:?}"), + } +} + +#[test] +fn add_metalink_registers_each_actionable_file_with_implied_defaults() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddMetalink, + vec![RpcValue::String( + r#" + + +900150983cd24fb0d6963f7d28e17f72 +http://mirror.example.org/alpha.bin + + + + + +https://backup.example.org/beta.bin +https://example.org/beta.bin + +"# + .to_owned(), + )], + )); + + let gids = match response.result { + Some(RpcValue::Array(items)) => items + .into_iter() + .map(|item| match item { + RpcValue::String(gid) => gid, + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }) + .collect::>(), + other => panic!("unexpected addMetalink result: {other:?}"), + }; + assert_eq!(gids.len(), 2); + assert_eq!(dispatcher.tracked_download_count(), 2); + + let first_options = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gids[0].clone())], + )); + match first_options.result { + Some(RpcValue::Object(options)) => { + assert_eq!( + options.get("out"), + Some(&RpcValue::String("alpha.bin".to_owned())) + ); + assert_eq!( + options.get("checksum"), + Some(&RpcValue::String( + "md5=900150983cd24fb0d6963f7d28e17f72".to_owned() + )) + ); + } + other => panic!("unexpected first getOption result after addMetalink: {other:?}"), + } + + let second_uris = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gids[1].clone())], + )); + match second_uris.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(entry)) => { + assert_eq!( + entry.get("uri"), + Some(&RpcValue::String("https://example.org/beta.bin".to_owned())) + ); + } + other => panic!("unexpected second getUris entry after addMetalink: {other:?}"), + }, + other => panic!("unexpected second getUris result after addMetalink: {other:?}"), + } +} + +#[test] +fn add_metalink_selects_preferred_resource_from_first_actionable_file() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddMetalink, + vec![RpcValue::String( + r#" + + + + + +https://mirror-b.example.org/picked.bin +https://mirror-a.example.org/picked.bin + + +https://later.example.org/later.bin + +"# + .to_owned(), + )], + )); + + let gid = match response.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }, + other => panic!("unexpected addMetalink result: {other:?}"), + }; + assert_eq!(dispatcher.tracked_download_count(), 2); + + let uris = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid.clone())], + )); + match uris.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(entry)) => { + assert_eq!( + entry.get("uri"), + Some(&RpcValue::String( + "https://mirror-a.example.org/picked.bin".to_owned() + )) + ); + } + other => panic!("unexpected getUris entry after addMetalink: {other:?}"), + }, + other => panic!("unexpected getUris result after addMetalink: {other:?}"), + } +} + +#[test] +fn add_metalink_rejects_invalid_document() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddMetalink, + vec![RpcValue::String("".to_owned())], + )); + + assert!(response.result.is_none()); + assert!(response.error.is_some()); +} + +#[test] +fn multicall_wraps_success_results_and_preserves_order() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::SystemMulticall, + vec![RpcValue::Array(vec![ + RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("aria2.getVersion".to_owned()), + ), + ("params".to_owned(), RpcValue::Array(Vec::new())), + ])), + RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("system.listMethods".to_owned()), + ), + ("params".to_owned(), RpcValue::Array(Vec::new())), + ])), + ])], + )); + + match response.result { + Some(RpcValue::Array(items)) => { + assert_eq!(items.len(), 2); + match &items[0] { + RpcValue::Array(first) => match first.first() { + Some(RpcValue::Object(payload)) => { + assert!(payload.contains_key("version")); + } + other => panic!("unexpected first multicall payload: {other:?}"), + }, + other => panic!("unexpected first multicall item: {other:?}"), + } + match &items[1] { + RpcValue::Array(second) => match second.first() { + Some(RpcValue::Array(methods)) => { + assert!(!methods.is_empty()); + } + other => panic!("unexpected second multicall payload: {other:?}"), + }, + other => panic!("unexpected second multicall item: {other:?}"), + } + } + other => panic!("unexpected multicall result: {other:?}"), + } +} + +#[test] +fn multicall_returns_error_object_for_invalid_member_and_missing_method_name() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::SystemMulticall, + vec![RpcValue::Array(vec![ + RpcValue::String("bad".to_owned()), + RpcValue::Object(BTreeMap::new()), + ])], + )); + + match response.result { + Some(RpcValue::Array(items)) => { + assert_eq!(items.len(), 2); + for item in items { + match item { + RpcValue::Object(payload) => { + assert!(payload.contains_key("code")); + assert!(payload.contains_key("message")); + } + other => panic!("unexpected multicall error item: {other:?}"), + } + } + } + other => panic!("unexpected multicall result: {other:?}"), + } +} + +#[test] +fn multicall_rejects_recursive_invocation() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::SystemMulticall, + vec![RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("system.multicall".to_owned()), + ), + ("params".to_owned(), RpcValue::Array(Vec::new())), + ]))])], + )); + + match response.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("message"), + Some(&RpcValue::String( + "Recursive system.multicall forbidden.".to_owned() + )) + ); + } + other => panic!("unexpected recursive multicall payload: {other:?}"), + }, + other => panic!("unexpected recursive multicall result: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/peer_wire_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/peer_wire_runtime.rs new file mode 100644 index 0000000..d287486 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/peer_wire_runtime.rs @@ -0,0 +1,777 @@ +use super::*; + +#[test] +fn execute_peer_wire_exchange_rejects_missing_or_invalid_runtime_peers() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:9999999999999999999999999999999999999999", + ); + let connector = FakePeerWireConnector::new(Vec::new()); + let download_id = download_id(&gid); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.peers.clear(); + } + let missing_peers = dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect_err("missing peers should be rejected"); + assert!( + missing_peers + .message + .contains("requires at least one bt peer"), + "unexpected missing-peer error: {}", + missing_peers.message + ); + + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.peers = vec![peer_from_ip("", 0), peer_from_ip("127.0.0.1", 0)]; + } + let invalid_peers = dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect_err("invalid peers should be rejected"); + assert!( + invalid_peers.message.contains("found no valid bt peers"), + "unexpected invalid-peer error: {}", + invalid_peers.message + ); + assert!(connector.seen().is_empty(), "transport should not be used"); +} + +#[test] +fn execute_peer_wire_exchange_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-peer-wire-gid".to_owned(); + let connector = FakePeerWireConnector::new(Vec::new()); + + let error = dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect_err("invalid gid should be rejected before connector use"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); + assert!(connector.seen().is_empty(), "connector should not be used"); +} + +#[test] +fn execute_peer_wire_exchange_builds_handshake_and_request_from_bt_runtime_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.metadata_only = false; + bt.peers = vec![peer_from_ip("127.0.0.2", 51413)]; + } + + let info_hash = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, 0x01, 0x23, 0x45, 0x67, + ]; + let remote_peer_id = *b"-UT0001-123456789012"; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + remote_peer_id, + &[], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange should succeed"); + + let seen = connector.seen(); + assert_eq!(seen.len(), 1, "transport should receive one request"); + assert_eq!(seen[0].endpoint.address, "127.0.0.2:51413"); + assert_eq!(seen[0].info_hash, info_hash.to_vec()); + assert_eq!(seen[0].peer_id.len(), 20); + + let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload) + .expect("request payload should begin with a valid handshake"); + assert_eq!(handshake.info_hash, info_hash); + assert_eq!(handshake.peer_id.as_slice(), seen[0].peer_id.as_slice()); + + let (interested, interested_len) = + TorrentMessageModel::parse_peer_wire_frame(&seen[0].payload[consumed..]) + .expect("interested frame should parse"); + assert_eq!( + interested.peer_wire_kind(), + Ok(PeerWireMessageKind::Interested) + ); + let request = TorrentMessageModel::parse_peer_wire_frame_exact( + &seen[0].payload[consumed + interested_len..], + ) + .expect("request frame should parse"); + match request.peer_wire_kind() { + Ok(PeerWireMessageKind::Request(block)) => { + assert_eq!(block.piece_index, 0); + assert_eq!(block.block_offset, 0); + assert_eq!(block.block_length, 1024); + } + other => panic!("unexpected peer-wire request frame: {other:?}"), + } +} + +#[test] +fn execute_peer_wire_exchange_metadata_only_sends_extension_handshake_and_learns_metadata() { + let torrent_bytes = single_file_torrent_bytes("metadata.iso", 0); + let metadata = parse_torrent_metadata(&torrent_bytes).expect("reference torrent should parse"); + let info_hash = metadata + .info + .hash + .as_ref() + .expect("reference torrent should expose info hash"); + let info_hash_bytes: [u8; 20] = + decode_hex_string_exact(&info_hash.info_hash_hex, 20, "test info hash") + .expect("reference torrent info hash bytes should decode") + .try_into() + .expect("reference torrent info hash should be 20 bytes"); + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + &format!( + "magnet:?xt=urn:btih:{}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + info_hash.info_hash_hex + ), + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.peers = vec![peer_from_ip("127.0.0.2", 51413)]; + } + + let extension_handshake = PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]), + client_name: Some("libtorrent/2.0.11".to_owned()), + metadata_size: Some(u32::try_from(torrent_bytes.len()).expect("test torrent fits u32")), + request_queue: Some(64), + }; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash_bytes, + *b"-LT0001-META-PEER-01", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Extension(extension_handshake.to_peer_wire_message()), + ], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("metadata-only exchange should succeed"); + + let seen = connector.seen(); + let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload) + .expect("request payload should begin with a valid handshake"); + assert!(handshake.extension_protocol_enabled()); + let extension = TorrentMessageModel::parse_peer_wire_frame_exact(&seen[0].payload[consumed..]) + .expect("extension handshake frame should parse"); + match extension.peer_wire_kind() { + Ok(PeerWireMessageKind::Extension(message)) => { + let decoded = PeerWireExtensionHandshakeModel::from_peer_wire_message(&message) + .expect("outbound extended handshake should decode"); + assert_eq!(decoded.ut_metadata_id(), Some(1)); + } + other => panic!("unexpected metadata-only outbound frame: {other:?}"), + } + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("download should remain registered"); + let bt = group.bt().expect("bt runtime state should remain present"); + assert!(bt.metadata_only); + assert_eq!( + bt.metadata_size, + Some(u32::try_from(torrent_bytes.len()).expect("test torrent fits u32")) + ); + assert_eq!( + bt.metadata_extension_ids.get("127.0.0.2:51413"), + Some(&3_u8) + ); + assert_eq!( + bt.peers + .first() + .and_then(|peer| peer.client_name.as_deref()), + Some("libtorrent/2.0.11") + ); +} + +#[test] +fn execute_peer_wire_exchange_promotes_metadata_only_magnet_to_torrent_surface() { + let torrent_bytes = single_file_torrent_bytes("promoted.iso", 17_000); + let metadata = parse_torrent_metadata(&torrent_bytes).expect("reference torrent should parse"); + let info_hash = metadata + .info + .hash + .as_ref() + .expect("reference torrent should expose info hash"); + let info_hash_bytes: [u8; 20] = + decode_hex_string_exact(&info_hash.info_hash_hex, 20, "test info hash") + .expect("reference torrent info hash bytes should decode") + .try_into() + .expect("reference torrent info hash should be 20 bytes"); + + let mut dispatcher = InProcessRpcDispatcher::new(); + let magnet_gid = add_uri( + &mut dispatcher, + &format!( + "magnet:?xt=urn:btih:{}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + info_hash.info_hash_hex + ), + ); + let magnet_download_id = download_id(&magnet_gid); + { + let group = dispatcher + .engine + .handle_mut(magnet_download_id) + .expect("magnet download should exist"); + let bt = group.bt_mut().expect("magnet runtime state should exist"); + bt.peers = vec![peer_from_ip("127.0.0.22", 51413)]; + } + + let torrent_response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String( + base64::engine::general_purpose::STANDARD.encode(&torrent_bytes), + )], + )); + let torrent_gid = match (torrent_response.result, torrent_response.error) { + (Some(RpcValue::String(gid)), None) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let metadata_size = + u32::try_from(torrent_bytes.len()).expect("test torrent metadata should fit u32"); + let piece_zero_len = bt_metadata_piece_span(metadata_size, 0); + let piece_one_len = bt_metadata_piece_span(metadata_size, 1); + let extension_handshake = PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]), + client_name: Some("libtorrent/2.0.11".to_owned()), + metadata_size: Some(metadata_size), + request_queue: Some(64), + }; + let connector = SequencedPeerWireConnector::new(vec![ + peer_wire_handshake_and_frames( + info_hash_bytes, + *b"-LT0001-META-PEER-02", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Extension(extension_handshake.to_peer_wire_message()), + PeerWireMessageKind::Extension( + PeerWireMetadataMessageModel::data( + 0, + metadata_size, + torrent_bytes[..piece_zero_len].to_vec(), + ) + .to_peer_wire_message(3), + ), + ], + ), + peer_wire_handshake_and_frames( + info_hash_bytes, + *b"-LT0001-META-PEER-02", + &[PeerWireMessageKind::Extension( + PeerWireMetadataMessageModel::data( + 1, + metadata_size, + torrent_bytes[piece_zero_len..piece_zero_len + piece_one_len].to_vec(), + ) + .to_peer_wire_message(3), + )], + ), + ]); + + dispatcher + .execute_peer_wire_exchange(&magnet_gid, &connector) + .expect("first metadata exchange should succeed"); + { + let group = dispatcher + .engine + .registry() + .get(magnet_download_id) + .expect("magnet download should remain registered"); + let bt = group.bt().expect("magnet bt runtime should remain present"); + assert!(bt.metadata_only); + assert_eq!(bt.metadata_piece_payloads.len(), 1); + } + + dispatcher + .execute_peer_wire_exchange(&magnet_gid, &connector) + .expect("second metadata exchange should promote torrent metadata"); + + let seen = connector.seen(); + let (first_handshake, first_consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload) + .expect("first request should begin with a valid handshake"); + assert!(first_handshake.extension_protocol_enabled()); + let first_extension = + TorrentMessageModel::parse_peer_wire_frame_exact(&seen[0].payload[first_consumed..]) + .expect("first outbound extension handshake should parse"); + assert!(matches!( + first_extension.peer_wire_kind(), + Ok(PeerWireMessageKind::Extension(_)) + )); + + let (_, second_consumed) = PeerWireHandshakeModel::parse_prefix(&seen[1].payload) + .expect("second request should begin with a valid handshake"); + let second_extension_handshake = + TorrentMessageModel::parse_peer_wire_frame(&seen[1].payload[second_consumed..]) + .expect("second outbound extension handshake should parse"); + assert!(matches!( + second_extension_handshake.0.peer_wire_kind(), + Ok(PeerWireMessageKind::Extension(_)) + )); + let second_extension = TorrentMessageModel::parse_peer_wire_frame_exact( + &seen[1].payload[second_consumed + second_extension_handshake.1..], + ) + .expect("second outbound metadata request should parse"); + match second_extension.peer_wire_kind() { + Ok(PeerWireMessageKind::Extension(message)) => { + let metadata_request = + PeerWireMetadataMessageModel::from_peer_wire_message(&message, 3) + .expect("second outbound extension should be a metadata request"); + assert_eq!( + metadata_request.message_type, + PeerWireMetadataMessageType::Request + ); + assert_eq!(metadata_request.piece, 1); + } + other => panic!("unexpected second outbound frame: {other:?}"), + } + + let magnet_status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(magnet_gid.clone())], + )); + let torrent_status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(torrent_gid.clone())], + )); + let magnet_files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(magnet_gid.clone())], + )); + let torrent_files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(torrent_gid.clone())], + )); + + match &magnet_status.result { + Some(RpcValue::Object(fields)) => { + assert_eq!(fields.get("metadataOnly"), Some(&RpcValue::Bool(false))); + assert_eq!( + fields.get("infoHash"), + torrent_status + .result + .as_ref() + .and_then(|result| match result { + RpcValue::Object(reference) => reference.get("infoHash"), + _ => None, + }) + ); + assert_eq!( + fields.get("totalLength"), + torrent_status + .result + .as_ref() + .and_then(|result| match result { + RpcValue::Object(reference) => reference.get("totalLength"), + _ => None, + }) + ); + } + other => panic!("unexpected promoted magnet tellStatus result: {other:?}"), + } + match (magnet_files.result, torrent_files.result) { + (Some(RpcValue::Array(mut magnet_items)), Some(RpcValue::Array(mut torrent_items))) => { + assert_eq!(magnet_items.len(), 1); + assert_eq!(torrent_items.len(), 1); + let Some(RpcValue::Object(magnet_file)) = magnet_items.pop() else { + panic!("unexpected promoted magnet getFiles payload"); + }; + let Some(RpcValue::Object(torrent_file)) = torrent_items.pop() else { + panic!("unexpected reference torrent getFiles payload"); + }; + for key in [ + "bitfield", + "btCompletedPieces", + "btPath", + "completedLength", + "index", + "isBt", + "length", + "numPieces", + "path", + "pieceLength", + "selected", + ] { + assert_eq!( + magnet_file.get(key), + torrent_file.get(key), + "mismatch for {key}" + ); + } + } + other => panic!("unexpected getFiles comparison payloads: {other:?}"), + } +} + +#[test] +fn execute_peer_wire_exchange_prefers_unchoked_peer_and_available_piece() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate { + piece_id: PieceId(1), + peers_with_piece: 1, + }); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.metadata_only = false; + bt.peers = vec![ + BtPeerInfo { + choked: true, + ..peer_from_ip("198.51.100.8", 51413) + }, + BtPeerInfo { + choked: false, + ..peer_from_ip("198.51.100.9", 51414) + }, + ]; + } + + let info_hash = [0xcc; 20]; + let remote_peer_id = *b"-LT1000-PEER-STATE01"; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + remote_peer_id, + &[PeerWireMessageKind::Unchoke], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange should succeed"); + + let seen = connector.seen(); + assert_eq!(seen.len(), 1, "transport should receive one request"); + assert_eq!(seen[0].endpoint.address, "198.51.100.9:51414"); + + let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload) + .expect("request payload should begin with a valid handshake"); + assert_eq!(handshake.info_hash, info_hash); + + let (interested, interested_len) = + TorrentMessageModel::parse_peer_wire_frame(&seen[0].payload[consumed..]) + .expect("interested frame should parse"); + assert_eq!( + interested.peer_wire_kind(), + Ok(PeerWireMessageKind::Interested) + ); + let request = TorrentMessageModel::parse_peer_wire_frame_exact( + &seen[0].payload[consumed + interested_len..], + ) + .expect("request frame should parse"); + match request.peer_wire_kind() { + Ok(PeerWireMessageKind::Request(block)) => { + assert_eq!(block.piece_index, 1); + assert_eq!(block.block_offset, 0); + assert_eq!(block.block_length, 1024); + } + other => panic!("unexpected peer-wire request frame: {other:?}"), + } +} + +#[test] +fn execute_peer_wire_exchange_uses_bitfield_and_have_to_update_peer_state_without_claiming_local_seeding() + { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + group.set_piece_state(PieceId(0), PieceState::Pending); + group.set_piece_state(PieceId(1), PieceState::Missing); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.metadata_only = false; + bt.peers = vec![peer_from_ip("198.51.100.2", 51413)]; + } + + let info_hash = [0xaa; 20]; + let remote_peer_id = *b"-TR3000-HELLO-WORLD!"; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + remote_peer_id, + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, true])), + PeerWireMessageKind::Have(1), + ], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire bitfield exchange should succeed"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("group should remain present"); + assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Downloading)); + assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1)); + assert_eq!(group.piece_availability().get(&PieceId(1)), Some(&1)); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("peerId"), + Some(&RpcValue::String( + "2d5452333030302d48454c4c4f2d574f524c4421".to_owned() + )) + ); + assert_eq!( + peer.get("peerChoking"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + peer.get("seeder"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => { + panic!("unexpected getPeers row after peer-wire bitfield exchange: {other:?}") + } + }, + other => { + panic!("unexpected getPeers result after peer-wire bitfield exchange: {other:?}") + } + } + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("bitfield"), + Some(&RpcValue::String("10".to_owned())) + ); + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("seeder"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("shareTime"), + Some(&RpcValue::String("0".to_owned())) + ); + } + other => { + panic!("unexpected tellStatus payload after peer-wire bitfield exchange: {other:?}") + } + } +} + +#[test] +fn execute_peer_wire_exchange_ignores_out_of_range_have_and_bitfield_pieces() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:abababababababababababababababababababab", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.metadata_only = false; + bt.peers = vec![peer_from_ip("198.51.100.3", 51413)]; + } + + let info_hash = [0xab; 20]; + let remote_peer_id = *b"-TR3000-RANGE-CHECK1"; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + remote_peer_id, + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[ + true, true, true, true, true, true, true, true, + ])), + PeerWireMessageKind::Have(7), + ], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange with out-of-range availability should succeed"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("group should remain present"); + assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1)); + assert_eq!(group.piece_availability().get(&PieceId(1)), Some(&1)); + assert_eq!(group.piece_availability().get(&PieceId(2)), None); + assert_eq!(group.piece_availability().get(&PieceId(7)), None); +} + +#[test] +fn execute_peer_wire_exchange_applies_piece_payload_to_completion_and_peer_metrics() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download should exist"); + group.set_piece_length(1024); + group.set_total_length(1024); + group.set_piece_state(PieceId(0), PieceState::Pending); + let bt = group.bt_mut().expect("bt runtime state should exist"); + bt.metadata_only = false; + bt.peers = vec![peer_from_ip("203.0.113.8", 60000)]; + } + + let info_hash = [0xbb; 20]; + let remote_peer_id = *b"-AZ2060-PIECE-FINISH"; + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + remote_peer_id, + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Piece(PeerWirePieceBlockModel { + piece_index: 0, + block_offset: 0, + block: vec![0x5a; 1024], + }), + ], + )); + + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire piece exchange should succeed"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("group should remain present"); + assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Verified)); + assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1)); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + payload.get("completedPieces"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("bitfield"), + Some(&RpcValue::String("2".to_owned())) + ); + } + other => { + panic!("unexpected tellStatus payload after peer-wire piece exchange: {other:?}") + } + } + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("downloadSpeed"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + peer.get("peerChoking"), + Some(&RpcValue::String("false".to_owned())) + ); + } + other => { + panic!("unexpected getPeers row after peer-wire piece exchange: {other:?}") + } + }, + other => panic!("unexpected getPeers result after peer-wire piece exchange: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/tracker_and_bridges.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/tracker_and_bridges.rs new file mode 100644 index 0000000..0b7654a --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/tracker_and_bridges.rs @@ -0,0 +1,926 @@ +use super::*; + +#[test] +fn apply_tracker_announce_result_keeps_tracker_metadata_visible_in_get_servers() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let announce = TrackerResponseModel { + peers: aria2_rust_pro_protocol::TrackerPeerListModel { + interval_sec: 1800, + peers: Vec::new(), + min_interval_sec: None, + tracker_id: Some("announce-tracker-id".to_owned()), + }, + scrape: Some(TrackerScrapeModel { + complete: Some(7), + downloaded: Some(11), + incomplete: Some(5), + files: Vec::new(), + }), + }; + dispatcher + .apply_tracker_announce_result(&gid, &announce) + .expect("tracker metadata should ingest"); + dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("download group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let servers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + match servers.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(server)) => { + assert_eq!(server.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(matches!( + server.get("servers"), + Some(RpcValue::Array(items)) + if matches!( + items.first(), + Some(RpcValue::Object(row)) + if row.get("uri") + == Some(&RpcValue::String( + "http://tracker.example.org/announce".to_owned() + )) + ) + )); + } + other => { + panic!("unexpected getServers entry after tracker metadata ingest: {other:?}") + } + }, + other => { + panic!("unexpected getServers result after tracker metadata ingest: {other:?}") + } + } +} + +#[test] +fn apply_tracker_scrape_result_updates_bt_seed_counts_without_peer_rows() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + ); + let scrape = TrackerScrapeModel { + complete: Some(15), + downloaded: Some(22), + incomplete: Some(8), + files: vec![aria2_rust_pro_protocol::TrackerScrapeFileModel { + info_hash: "cccccccccccccccccccccccccccccccccccccccc".to_owned(), + complete: Some(15), + downloaded: Some(22), + incomplete: Some(8), + }], + }; + + dispatcher + .apply_tracker_scrape_result(&gid, None, &scrape) + .expect("tracker scrape should ingest"); + dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("download group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("15".to_owned())) + ); + } + other => panic!("unexpected tellStatus after tracker scrape ingest: {other:?}"), + } + + let servers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + match servers.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(server)) => { + assert!(matches!( + server.get("servers"), + Some(RpcValue::Array(items)) + if matches!( + items.first(), + Some(RpcValue::Object(row)) + if row.get("currentUri") + == Some(&RpcValue::String( + "http://tracker.example.org/announce".to_owned() + )) + ) + )); + } + other => panic!("unexpected getServers row after scrape ingest: {other:?}"), + }, + other => panic!("unexpected getServers result after scrape ingest: {other:?}"), + } +} + +#[test] +fn execute_tracker_announce_fetches_live_tracker_data_and_updates_bt_views() { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + 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_text = String::from_utf8_lossy(&request[..read]); + let (payload, path) = if request_text.starts_with("GET /announce?") { + ( + b"d8:intervali600e10:tracker id12:rpc-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"); + } + }); + + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + &format!( + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=http%3A%2F%2F{addr}%2Fannounce" + ), + ); + let transport = + aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build"); + dispatcher + .execute_tracker_announce(&gid, &transport) + .expect("live tracker announce should succeed"); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("127.0.0.1".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); + } + other => panic!("unexpected peer row after live tracker announce: {other:?}"), + }, + other => panic!("unexpected getPeers result after live tracker announce: {other:?}"), + } + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("4".to_owned())) + ); + } + other => panic!("unexpected tellStatus payload after live tracker announce: {other:?}"), + } + + handle.join().expect("tracker server thread should join"); +} + +#[test] +fn execute_tracker_announce_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-tracker-gid".to_owned(); + let transport = + aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build"); + + let error = dispatcher + .execute_tracker_announce(&gid, &transport) + .expect_err("invalid gid should be rejected before tracker transport"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn execute_tracker_scrape_fetches_live_scrape_data_and_updates_bt_views() { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + 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 || { + 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_text = String::from_utf8_lossy(&request[..read]); + assert!(request_text.starts_with("GET /scrape")); + let payload = b"d5:filesd20:\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xddd8:completei11e10:downloadedi17e10:incompletei4eeee".to_vec(); + 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"); + }); + + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + &format!( + "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&tr=http%3A%2F%2F{addr}%2Fannounce" + ), + ); + let transport = + aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build"); + dispatcher + .execute_tracker_scrape(&gid, &transport) + .expect("live tracker scrape should succeed"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("11".to_owned())) + ); + } + other => panic!("unexpected tellStatus after live tracker scrape: {other:?}"), + } + + handle.join().expect("tracker server thread should join"); +} + +#[test] +fn apply_dht_get_peers_result_updates_bt_peers_and_discovers_more_nodes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + ); + let download_id = DownloadId::parse_hex(&gid).expect("gid should parse"); + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should exist"); + let mut bt = group.bt().cloned().expect("magnet should have bt state"); + bt.dht_nodes = vec!["192.0.2.10:6881".to_owned()]; + group.set_bt(bt); + + let mut compact_node = vec![0x44_u8; 20]; + compact_node.extend_from_slice(&[198, 51, 100, 77]); + compact_node.extend_from_slice(&51413_u16.to_be_bytes()); + let response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x33_u8; 20], + Some(b"tok".to_vec()), + Some(compact_node), + vec![vec![203, 0, 113, 10, 0x1a, 0xe1]], + ); + let node = DhtNodeModel { + node_id: String::new(), + address: "192.0.2.10".to_owned(), + port: 6881, + }; + + dispatcher + .apply_dht_get_peers_result(&gid, &node, &response) + .expect("dht get_peers apply should succeed"); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("203.0.113.10".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); + } + other => panic!("unexpected peer row after dht get_peers apply: {other:?}"), + }, + other => panic!("unexpected getPeers result after dht get_peers apply: {other:?}"), + } + + let saved = dispatcher + .engine + .registry() + .get(download_id) + .expect("group should still exist"); + let bt = saved.bt().expect("bt state should remain present"); + assert!(bt.dht_nodes.iter().any(|node| node == "192.0.2.10:6881")); + assert!( + bt.dht_nodes + .iter() + .any(|node| node == "198.51.100.77:51413") + ); +} + +#[test] +fn execute_dht_announce_peer_uses_cached_token_and_promotes_node() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let download_id = DownloadId::parse_hex(&gid).expect("gid should parse"); + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should exist"); + let mut bt = group.bt().cloned().expect("magnet should have bt state"); + bt.dht_nodes = vec!["bad-node-entry".to_owned(), "192.0.2.10:6881".to_owned()]; + group.set_bt(bt); + + let get_peers_response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x11; 20], + Some(b"tok".to_vec()), + None, + Vec::new(), + ); + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "192.0.2.10".to_owned(), + port: 6881, + }, + &get_peers_response, + ) + .expect("get_peers should cache a token"); + + let transport = FakeDhtTransport::new(DhtMessageModel::ping_response( + b"ap".to_vec(), + vec![0x22; 20], + )); + + dispatcher + .execute_dht_announce_peer(&gid, &transport) + .expect("announce_peer should succeed"); + + let seen = transport.seen(); + assert_eq!(seen.len(), 1, "transport should see exactly one request"); + assert_eq!(seen[0].0.address, "192.0.2.10"); + assert_eq!(seen[0].0.port, 6881); + match &seen[0].1.body { + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => { + assert_eq!(query.token, b"tok".to_vec()); + assert_eq!(query.port, 6881); + assert!(!query.implied_port); + } + other => panic!("unexpected announce_peer request body: {other:?}"), + } + + let bt = dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.bt()) + .expect("bt runtime state should remain present"); + assert_eq!( + bt.dht_nodes.first().map(String::as_str), + Some("192.0.2.10:6881") + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id) + .and_then(|group| group.dht_token().map(|token| token.to_vec())), + Some(b"tok".to_vec()) + ); +} + +#[test] +fn execute_dht_announce_peer_requires_cached_token_and_valid_response() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + let download_id = DownloadId::parse_hex(&gid).expect("gid should parse"); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should exist"); + let mut bt = group.bt().cloned().expect("magnet should have bt state"); + bt.dht_nodes = vec!["192.0.2.11:6881".to_owned()]; + group.set_bt(bt); + } + + let missing_token = dispatcher + .execute_dht_announce_peer( + &gid, + &FakeDhtTransport::new(DhtMessageModel::ping_response( + b"ap".to_vec(), + vec![0x22; 20], + )), + ) + .expect_err("announce_peer should require a cached token"); + assert!( + missing_token + .message + .contains("requires token from prior get_peers"), + "unexpected missing-token error: {}", + missing_token.message + ); + + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should still exist"); + group.set_dht_token(Some(b"tok".to_vec())); + + let invalid_response = dispatcher + .apply_dht_announce_peer_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "192.0.2.11".to_owned(), + port: 6881, + }, + &DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x22; 19]), + ) + .expect_err("short node id should be rejected"); + assert!( + invalid_response + .message + .contains("node id must be 20 bytes") + ); +} + +#[test] +fn execute_dht_get_peers_fetches_live_data_and_updates_bt_views() { + use std::cell::RefCell; + + struct FakeDhtTransport { + response: DhtMessageModel, + seen_nodes: RefCell>, + seen_methods: RefCell>>, + } + + impl DhtTransport for FakeDhtTransport { + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + self.seen_nodes + .borrow_mut() + .push(format!("{}:{}", node.address, node.port)); + self.seen_methods.borrow_mut().push(message.method()); + Ok(self.response.clone()) + } + } + + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef", + ); + let download_id = DownloadId::parse_hex(&gid).expect("gid should parse"); + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("group should exist"); + let mut bt = group.bt().cloned().expect("magnet should have bt state"); + bt.dht_nodes = vec!["192.0.2.30:7000".to_owned()]; + group.set_bt(bt); + + let transport = FakeDhtTransport { + response: DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x55_u8; 20], + None, + None, + vec![vec![198, 51, 100, 22, 0x13, 0x89]], + ), + seen_nodes: RefCell::new(Vec::new()), + seen_methods: RefCell::new(Vec::new()), + }; + + dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect("dht get_peers execute should succeed"); + + assert_eq!( + transport.seen_nodes.borrow().as_slice(), + &["192.0.2.30:7000".to_owned()] + ); + assert_eq!( + transport.seen_methods.borrow().as_slice(), + &[Some("get_peers")] + ); + + let peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("198.51.100.22".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("5001".to_owned()))); + } + other => panic!("unexpected peer row after dht get_peers execute: {other:?}"), + }, + other => panic!("unexpected getPeers result after dht execute: {other:?}"), + } +} + +#[test] +fn apply_bt_runtime_tick_bridges_hex_gid_into_engine_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download group should exist"); + group.set_bt(BtRuntimeState { + files: vec![BtFileInfo { + path: "file.bin".to_owned(), + length: 2_048, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + } + + dispatcher + .apply_bt_runtime_tick(&gid, 2_048, 1_024, 90, 180, 5, 5, true, Some(16)) + .expect("runtime tick wrapper should bridge"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("download group should exist"); + + assert!(group.bt_is_seeding()); + assert_eq!(group.completed_length(), 2_048); + assert_eq!(group.upload_length(), 1_024); + assert_eq!(group.download_speed(), 90); + assert_eq!(group.bt_share_ratio_milli(), Some(500)); + assert_eq!(group.bt_share_time_secs(), Some(5)); + assert_eq!(group.bt_seeding_time_secs(), Some(5)); + assert_eq!(group.upload_speed(), 180); + assert_eq!(group.num_connections(), 16); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String("180".to_owned())) + ); + assert_eq!( + payload.get("shareTime"), + Some(&RpcValue::String("5".to_owned())) + ); + assert_eq!( + payload.get("shareRatio"), + Some(&RpcValue::String("0.500".to_owned())) + ); + } + other => panic!("unexpected tellStatus payload after bt runtime wrappers: {other:?}"), + } + + let tick_error = dispatcher + .apply_bt_runtime_tick("0123456789abcdeg", 0, 0, 0, 0, 0, 0, false, None) + .expect_err("invalid hex gid should be rejected"); + assert_eq!(tick_error.kind, crate::model::RpcErrorKind::Unsupported); +} + +#[test] +fn tick_bt_runtime_clock_bridges_hex_gid_into_engine_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download group should exist"); + group.set_bt(BtRuntimeState { + files: vec![BtFileInfo { + path: "clock.bin".to_owned(), + length: 2_048, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + } + + dispatcher + .engine + .set_bt_seeding_state(download_id, true, Some(1_000)) + .expect("bt state setup should succeed"); + + dispatcher + .tick_bt_runtime_clock(&gid, 1_040, true) + .expect("clock tick wrapper should bridge"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("download group should exist"); + assert!(group.bt_is_seeding()); + assert_eq!(group.bt_share_time_secs(), Some(40)); + assert_eq!(group.bt_seeding_time_secs(), Some(40)); + + let clock_error = dispatcher + .tick_bt_runtime_clock("0123456789abcdeg", 1, false) + .expect_err("invalid hex gid should be rejected"); + assert_eq!(clock_error.kind, crate::model::RpcErrorKind::Unsupported); +} + +#[test] +fn set_bt_seeding_state_bridges_hex_gid_into_engine_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + ); + let download_id = download_id(&gid); + { + let group = dispatcher + .engine + .handle_mut(download_id) + .expect("download group should exist"); + group.set_bt(BtRuntimeState { + files: vec![BtFileInfo { + path: "seeding.bin".to_owned(), + length: 2_048, + piece_offset: Some(0), + selected: true, + }], + ..BtRuntimeState::default() + }); + } + + dispatcher + .set_bt_seeding_state(&gid, true, Some(1_000)) + .expect("starting seeding should bridge"); + dispatcher + .set_bt_seeding_state(&gid, false, Some(1_030)) + .expect("stopping seeding should bridge"); + + let group = dispatcher + .engine + .registry() + .get(download_id) + .expect("download group should exist"); + assert!(!group.bt_is_seeding()); + assert_eq!(group.bt_share_time_secs(), Some(30)); + assert_eq!(group.bt_seeding_time_secs(), Some(30)); + + let seeding_error = dispatcher + .set_bt_seeding_state("0123456789abcdeg", true, None) + .expect_err("invalid hex gid should be rejected"); + assert_eq!(seeding_error.kind, crate::model::RpcErrorKind::Unsupported); +} + +#[test] +fn tell_status_bt_fields_for_non_bt_download_do_not_claim_magnet_metadata() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/not-bt.bin"); + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(false))); + assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false))); + assert_eq!( + payload.get("magnetUri"), + Some(&RpcValue::String(String::new())) + ); + assert_eq!( + payload.get("infoHash"), + Some(&RpcValue::String(String::new())) + ); + } + other => panic!("unexpected tellStatus non-bt payload: {other:?}"), + } +} + +#[test] +fn change_global_option_and_change_option_feed_speed_limit_runtime_surfaces() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233", + ); + + let global = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-overall-download-limit".to_owned(), + RpcValue::String("1200".to_owned()), + ), + ( + "max-overall-upload-limit".to_owned(), + RpcValue::String("600".to_owned()), + ), + ("disk-cache".to_owned(), RpcValue::String("32M".to_owned())), + ]))], + )); + assert!(global.error.is_none(), "changeGlobalOption should succeed"); + + let per_download = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([ + ( + "max-download-limit".to_owned(), + RpcValue::String("700".to_owned()), + ), + ( + "max-upload-limit".to_owned(), + RpcValue::String("200".to_owned()), + ), + ])), + ], + )); + assert!(per_download.error.is_none(), "changeOption should succeed"); + + dispatcher + .apply_bt_runtime_tick(&gid, 2048, 1024, 2_000, 900, 1, 1, false, Some(4)) + .expect("bt runtime tick should succeed"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String("700".to_owned())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String("200".to_owned())) + ); + } + other => panic!("unexpected tellStatus payload after limit change: {other:?}"), + } + + let global_stat = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![])); + match global_stat.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String("700".to_owned())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String("200".to_owned())) + ); + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!(payload.len(), 6); + } + other => panic!("unexpected tellGlobalStat payload after limit change: {other:?}"), + } +} + +#[test] +fn tell_global_stat_uses_upstream_field_set_and_stopped_counters() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let _waiting = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + let active = add_uri(&mut dispatcher, "https://example.org/active.iso"); + dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&active).expect("gid should parse")) + .expect("group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + let removed = add_uri(&mut dispatcher, "https://example.org/removed.iso"); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Remove, + vec![RpcValue::String(removed.clone())], + )); + + let global = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![])); + match global.result { + Some(RpcValue::Object(payload)) => { + let keys = payload.keys().cloned().collect::>(); + assert_eq!( + keys, + vec![ + "downloadSpeed".to_owned(), + "numActive".to_owned(), + "numStopped".to_owned(), + "numStoppedTotal".to_owned(), + "numWaiting".to_owned(), + "uploadSpeed".to_owned(), + ] + ); + assert_eq!( + payload.get("numActive"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numWaiting"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numStopped"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("1".to_owned())) + ); + assert!(!payload.contains_key("numError")); + assert!(!payload.contains_key("numComplete")); + assert!(!payload.contains_key("totalLength")); + assert!(!payload.contains_key("completedLength")); + } + other => panic!("unexpected tellGlobalStat upstream-shape result: {other:?}"), + } + + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2RemoveDownloadResult, + vec![RpcValue::String(removed)], + )); + let after_purge = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![])); + match after_purge.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numStopped"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("1".to_owned())) + ); + } + other => panic!("unexpected tellGlobalStat after purge result: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/protocol_surface.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/protocol_surface.rs new file mode 100644 index 0000000..3b076af --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/protocol_surface.rs @@ -0,0 +1,379 @@ +use super::*; + +#[test] +fn dispatch_xml_get_version_returns_struct_with_enabled_features() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(xml_request("aria2.getVersion")); + + assert!(response.fault.is_none(), "expected XML-RPC success"); + match response.value { + Some(XmlRpcValue::Struct(members)) => { + let version = members.iter().find(|member| member.name == "version"); + assert_eq!( + version.map(|member| &member.value), + Some(&XmlRpcValue::String( + aria2_rust_pro_compat::VERSION.to_owned() + )) + ); + let features = members + .iter() + .find(|member| member.name == "enabledFeatures") + .expect("enabledFeatures member should exist"); + match &features.value { + XmlRpcValue::Array(values) => { + assert_eq!( + values, + &vec![ + XmlRpcValue::String("Async DNS".to_owned()), + XmlRpcValue::String("BitTorrent".to_owned()), + XmlRpcValue::String("GZip".to_owned()), + XmlRpcValue::String("HTTPS".to_owned()), + XmlRpcValue::String("Message Digest".to_owned()), + XmlRpcValue::String("Metalink".to_owned()), + XmlRpcValue::String("XML-RPC".to_owned()), + XmlRpcValue::String("SFTP".to_owned()), + ] + ); + } + other => panic!("unexpected enabledFeatures value: {other:?}"), + } + } + other => panic!("unexpected aria2.getVersion XML-RPC payload: {other:?}"), + } +} + +#[test] +fn get_version_returns_package_version_and_upstream_style_features() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetVersion, vec![])); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("version"), + Some(&RpcValue::String(aria2_rust_pro_compat::VERSION.to_owned())) + ); + let features = match payload.get("enabledFeatures") { + Some(RpcValue::Array(features)) => features, + other => panic!("unexpected enabledFeatures payload: {other:?}"), + }; + assert_eq!( + features, + &vec![ + RpcValue::String("Async DNS".to_owned()), + RpcValue::String("BitTorrent".to_owned()), + RpcValue::String("GZip".to_owned()), + RpcValue::String("HTTPS".to_owned()), + RpcValue::String("Message Digest".to_owned()), + RpcValue::String("Metalink".to_owned()), + RpcValue::String("XML-RPC".to_owned()), + RpcValue::String("SFTP".to_owned()), + ] + ); + } + other => panic!("unexpected aria2.getVersion JSON-RPC payload: {other:?}"), + } +} + +#[test] +fn get_session_info_returns_only_hex_session_id() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetSessionInfo, vec![])); + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.len(), 1, "upstream payload only exposes sessionId"); + let session_id = match payload.get("sessionId") { + Some(RpcValue::String(value)) => value, + other => panic!("unexpected sessionId payload: {other:?}"), + }; + assert_eq!( + session_id.len(), + 40, + "sessionId should be 20 bytes rendered as hex" + ); + assert!( + session_id.chars().all(|ch| ch.is_ascii_hexdigit()), + "sessionId should contain only hexadecimal characters: {session_id}" + ); + } + other => panic!("unexpected aria2.getSessionInfo JSON-RPC payload: {other:?}"), + } +} + +#[test] +fn dispatch_xml_get_session_info_returns_only_hex_session_id() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(xml_request("aria2.getSessionInfo")); + + assert!(response.fault.is_none(), "expected XML-RPC success"); + match response.value { + Some(XmlRpcValue::Struct(members)) => { + assert_eq!(members.len(), 1, "upstream payload only exposes sessionId"); + let session = members + .iter() + .find(|member| member.name == "sessionId") + .expect("sessionId member should exist"); + let session_id = match &session.value { + XmlRpcValue::String(value) => value, + other => panic!("unexpected sessionId XML-RPC value: {other:?}"), + }; + assert_eq!( + session_id.len(), + 40, + "sessionId should be 20 bytes rendered as hex" + ); + assert!( + session_id.chars().all(|ch| ch.is_ascii_hexdigit()), + "sessionId should contain only hexadecimal characters: {session_id}" + ); + } + other => panic!("unexpected aria2.getSessionInfo XML-RPC payload: {other:?}"), + } +} + +#[test] +fn dispatch_xml_get_global_stat_reuses_real_rpc_payload() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let response = dispatcher.dispatch_xml(xml_request("aria2.getGlobalStat")); + assert!(response.fault.is_none(), "expected XML-RPC success"); + match response.value { + Some(XmlRpcValue::Struct(members)) => { + assert!(members.iter().any(|member| { + member.name == "numActive" && member.value == XmlRpcValue::String("1".to_owned()) + })); + assert!(members.iter().any(|member| { + member.name == "numStoppedTotal" + && member.value == XmlRpcValue::String("0".to_owned()) + })); + assert!(!members.iter().any(|member| member.name == "totalLength")); + assert!( + !members + .iter() + .any(|member| member.name == "completedLength") + ); + } + other => panic!("unexpected aria2.getGlobalStat XML-RPC payload: {other:?}"), + } +} + +#[test] +fn dispatch_xml_tell_status_reuses_real_rpc_payload_and_params() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/xml-status.bin"); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_num_connections(2); + group.set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let response = dispatcher.dispatch_xml(xml_request_with_params( + "aria2.tellStatus", + vec![XmlRpcValue::String(gid)], + )); + assert!(response.fault.is_none(), "expected XML-RPC success"); + match response.value { + Some(XmlRpcValue::Struct(members)) => { + assert!(members.iter().any(|member| { + member.name == "status" && member.value == XmlRpcValue::String("active".to_owned()) + })); + assert!(members.iter().any(|member| { + member.name == "completedLength" + && member.value == XmlRpcValue::String("1024".to_owned()) + })); + assert!(members.iter().any(|member| { + member.name == "connections" && member.value == XmlRpcValue::String("2".to_owned()) + })); + } + other => panic!("unexpected aria2.tellStatus XML-RPC payload: {other:?}"), + } +} + +#[test] +fn dispatch_xml_unknown_method_uses_upstream_fault_code_one() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(xml_request("aria2.notFound")); + + assert!( + response.value.is_none(), + "unknown methods should return fault" + ); + let fault = response.fault.expect("fault payload should exist"); + assert_eq!(fault.code, 1); + assert_eq!( + fault.message, + RpcError::unknown_method("aria2.notFound").message + ); + assert_eq!( + fault.error, + Some(RpcError::unknown_method("aria2.notFound")) + ); +} + +#[test] +fn dispatch_xml_get_version_response_can_be_rendered_to_method_response_xml() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(xml_request("aria2.getVersion")); + assert!(response.fault.is_none(), "expected XML-RPC success"); + let xml = crate::xmlrpc::xmlrpc_method_response_to_xml(&response); + assert!( + xml.starts_with("") + ); + assert!(xml.contains("version")); + assert!(xml.contains("enabledFeatures")); + assert!(xml.contains("XML-RPC")); + assert!(!xml.contains("JSON-RPC")); + assert!(xml.ends_with("")); +} + +#[test] +fn parsed_jsonrpc_success_response_renders_transport_visible_result_without_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let request = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","id":"wire-success","method":"aria2.getVersion","params":[]}"#, + ) + .expect("raw JSON-RPC request should parse"); + + let body = jsonrpc_response_to_json(&dispatcher.dispatch_json(request)) + .expect("JSON-RPC success response should render"); + let value: serde_json::Value = + serde_json::from_str(&body).expect("rendered response should be valid JSON"); + + assert_eq!(value.get("jsonrpc"), Some(&serde_json::json!("2.0"))); + assert_eq!(value.get("id"), Some(&serde_json::json!("wire-success"))); + assert!( + value.get("error").is_none(), + "successful JSON-RPC response must not expose an error member: {body}" + ); + assert_eq!( + value.pointer("/result/version"), + Some(&serde_json::json!(aria2_rust_pro_compat::VERSION)) + ); + assert_eq!( + value.pointer("/result/enabledFeatures/0"), + Some(&serde_json::json!("Async DNS")) + ); +} + +#[test] +fn dispatch_xml_multicall_wraps_success_results_and_preserves_order() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(XmlRpcMethodCall { + method_name: "system.multicall".to_owned(), + params: vec![XmlRpcParam { + value: XmlRpcValue::Array(vec![ + XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "methodName".to_owned(), + value: XmlRpcValue::String("aria2.getVersion".to_owned()), + }, + XmlRpcMember { + name: "params".to_owned(), + value: XmlRpcValue::Array(Vec::new()), + }, + ]), + XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "methodName".to_owned(), + value: XmlRpcValue::String("system.listMethods".to_owned()), + }, + XmlRpcMember { + name: "params".to_owned(), + value: XmlRpcValue::Array(Vec::new()), + }, + ]), + ]), + }], + meta: RpcMeta::default(), + }); + + assert!( + response.fault.is_none(), + "expected XML-RPC multicall success" + ); + match response.value { + Some(XmlRpcValue::Array(items)) => { + assert_eq!(items.len(), 2); + match &items[0] { + XmlRpcValue::Array(first) => match first.first() { + Some(XmlRpcValue::Struct(payload)) => { + assert!(payload.iter().any(|member| member.name == "version")); + } + other => panic!("unexpected first XML multicall payload: {other:?}"), + }, + other => panic!("unexpected first XML multicall item: {other:?}"), + } + match &items[1] { + XmlRpcValue::Array(second) => match second.first() { + Some(XmlRpcValue::Array(methods)) => { + assert!(!methods.is_empty()); + } + other => panic!("unexpected second XML multicall payload: {other:?}"), + }, + other => panic!("unexpected second XML multicall item: {other:?}"), + } + } + other => panic!("unexpected XML multicall result: {other:?}"), + } +} + +#[test] +fn dispatch_xml_multicall_invalid_members_use_fault_code_and_fault_string() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_xml(XmlRpcMethodCall { + method_name: "system.multicall".to_owned(), + params: vec![XmlRpcParam { + value: XmlRpcValue::Array(vec![ + XmlRpcValue::String("bad".to_owned()), + XmlRpcValue::Struct(Vec::new()), + XmlRpcValue::Struct(vec![XmlRpcMember { + name: "methodName".to_owned(), + value: XmlRpcValue::String("system.multicall".to_owned()), + }]), + ]), + }], + meta: RpcMeta::default(), + }); + + assert!( + response.fault.is_none(), + "expected in-band XML multicall errors" + ); + match response.value { + Some(XmlRpcValue::Array(items)) => { + assert_eq!(items.len(), 3); + for item in &items[..2] { + match item { + XmlRpcValue::Struct(payload) => { + assert!(payload.iter().any(|member| member.name == "faultCode")); + assert!(payload.iter().any(|member| member.name == "faultString")); + } + other => panic!("unexpected XML multicall error item: {other:?}"), + } + } + match &items[2] { + XmlRpcValue::Struct(payload) => { + assert!(payload.iter().any(|member| { + member.name == "faultString" + && member.value + == XmlRpcValue::String( + "Recursive system.multicall forbidden.".to_owned(), + ) + })); + } + other => panic!("unexpected recursive XML multicall item: {other:?}"), + } + } + other => panic!("unexpected XML multicall result: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options.rs new file mode 100644 index 0000000..502b393 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options.rs @@ -0,0 +1,7 @@ +pub(super) use super::*; + +mod additions_and_state; +mod options_and_files; +mod queue_and_uri; +mod queue_views_and_transfer; +mod status_and_global; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/additions_and_state.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/additions_and_state.rs new file mode 100644 index 0000000..d26d5bd --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/additions_and_state.rs @@ -0,0 +1,488 @@ +use super::*; + +#[test] +fn add_uri_accepts_uri_array_and_applies_options() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddUri, + vec![ + RpcValue::Array(vec![RpcValue::String( + "https://example.org/file.iso".to_owned(), + )]), + RpcValue::Object(BTreeMap::from([ + ("dir".to_owned(), RpcValue::String("/downloads".to_owned())), + ("out".to_owned(), RpcValue::String("file.iso".to_owned())), + ])), + RpcValue::Number(0), + ], + )); + + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + let options = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + match options.result { + Some(RpcValue::Object(options)) => { + assert_eq!( + options.get("dir"), + Some(&RpcValue::String("/downloads".to_owned())) + ); + assert_eq!( + options.get("out"), + Some(&RpcValue::String("file.iso".to_owned())) + ); + } + other => panic!("unexpected getOption result after addUri: {other:?}"), + } +} + +#[test] +fn add_uri_rejects_non_numeric_position() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddUri, + vec![ + RpcValue::Array(vec![RpcValue::String( + "https://example.org/file.iso".to_owned(), + )]), + RpcValue::Object(BTreeMap::new()), + RpcValue::String("front".to_owned()), + ], + )); + + assert!(matches!(response.error, Some(error) if error.message.contains("position"))); +} + +#[test] +fn add_torrent_registers_bt_like_download() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String(torrent_payload.to_owned())], + )); + + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(payload.contains_key("magnetUri")); + assert!(payload.contains_key("btFieldCoverage")); + assert_eq!( + payload.get("metadataOnly"), + Some(&RpcValue::Bool(false)), + "torrent-file downloads should not be treated as metadataOnly" + ); + assert!(matches!( + payload.get("announceList"), + Some(RpcValue::Array(tiers)) if !tiers.is_empty() + )); + assert!(matches!( + payload.get("infoHash"), + Some(RpcValue::String(info_hash)) + if info_hash.len() == 40 && info_hash.chars().all(|ch| ch.is_ascii_hexdigit()) + )); + } + other => panic!("unexpected tellStatus after addTorrent: {other:?}"), + } + + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + match files.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("path"), + Some(&RpcValue::String("ubuntu.iso".to_owned())) + ); + assert_eq!( + file.get("length"), + Some(&RpcValue::String("32768".to_owned())) + ); + assert_eq!( + file.get("selected"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected addTorrent file payload: {other:?}"), + }, + other => panic!("unexpected getFiles after addTorrent: {other:?}"), + } + dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("download group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let servers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + match servers.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(server)) => { + assert_eq!(server.get("isBt"), Some(&RpcValue::Bool(true))); + } + other => panic!("unexpected addTorrent server payload: {other:?}"), + }, + other => panic!("unexpected getServers after addTorrent: {other:?}"), + } +} + +#[test] +fn add_torrent_accepts_webseed_array_and_applies_options() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddTorrent, + vec![ + RpcValue::String(torrent_payload.to_owned()), + RpcValue::Array(vec![RpcValue::String( + "https://seed.example.org/ubuntu.iso".to_owned(), + )]), + RpcValue::Object(BTreeMap::from([( + "dir".to_owned(), + RpcValue::String("/torrent-downloads".to_owned()), + )])), + RpcValue::Number(0), + ], + )); + + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + let options = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + match options.result { + Some(RpcValue::Object(options)) => { + assert_eq!( + options.get("dir"), + Some(&RpcValue::String("/torrent-downloads".to_owned())) + ); + } + other => panic!("unexpected getOption result after addTorrent: {other:?}"), + } +} + +#[test] +fn change_option_select_file_updates_bt_file_selected_flags() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String(torrent_payload.to_owned())], + )); + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + let mut bt = group.bt().cloned().expect("torrent should have bt state"); + bt.files = vec![ + BtFileInfo { + path: "episode-01.mkv".to_owned(), + length: 10, + piece_offset: Some(0), + selected: true, + }, + BtFileInfo { + path: "episode-02.mkv".to_owned(), + length: 10, + piece_offset: Some(10), + selected: true, + }, + BtFileInfo { + path: "episode-03.mkv".to_owned(), + length: 10, + piece_offset: Some(20), + selected: true, + }, + ]; + group.set_bt(bt); + + let changed = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("2-3".to_owned()), + )])), + ], + )); + assert_eq!(changed.result, Some(RpcValue::String("OK".to_owned()))); + + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + match files.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 3); + let selected: Vec = entries + .iter() + .map(|entry| match entry { + RpcValue::Object(file) => match file.get("selected") { + Some(RpcValue::String(value)) => value.clone(), + other => panic!("unexpected selected payload: {other:?}"), + }, + other => panic!("unexpected file row: {other:?}"), + }) + .collect(); + assert_eq!(selected, vec!["false", "true", "true"]); + } + other => panic!("unexpected getFiles result after select-file change: {other:?}"), + } +} + +#[test] +fn bt_pause_and_unpause_keep_bt_status_payload_shape() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri( + &mut dispatcher, + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=PauseResumeBt", + ); + let pause = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + assert!(pause.error.is_none(), "pause should succeed for bt group"); + assert!(pause.result.is_some(), "pause should return a payload"); + let unpause = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid.clone())], + )); + assert!( + unpause.error.is_none(), + "unpause should succeed for bt group" + ); + assert!(unpause.result.is_some(), "unpause should return a payload"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("waiting".to_owned())) + ); + for key in crate::model::BT_STATUS_FIELDS { + assert!( + payload.contains_key(*key), + "BT status payload missing key `{key}` after pause/unpause" + ); + } + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(matches!(payload.get("files"), Some(RpcValue::Array(_)))); + } + other => panic!("unexpected tellStatus payload after bt pause/unpause: {other:?}"), + } +} + +#[test] +fn pause_remove_and_unpause_return_gid_strings() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/state.bin"); + + let pause = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(pause.result, Some(RpcValue::String(gid.clone()))); + + let unpause = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(unpause.result, Some(RpcValue::String(gid.clone()))); + + let remove = dispatcher.dispatch_json(request( + RpcMethod::Aria2Remove, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(remove.result, Some(RpcValue::String(gid.clone()))); +} + +#[test] +fn pause_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000005".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("missing gid should be rejected with upstream-style pause error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("GID#{gid} cannot be paused now")); +} + +#[test] +fn pause_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-pause-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected with upstream-style pause error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn pause_reports_upstream_style_invalid_state_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/already-paused.bin"); + let first = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(first.result, Some(RpcValue::String(gid.clone()))); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("second pause should be rejected with upstream-style pause error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("GID#{gid} cannot be paused now")); +} + +#[test] +fn unpause_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-unpause-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected with upstream-style unpause error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn unpause_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000006".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("missing gid should be rejected with upstream-style unpause error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("GID#{gid} cannot be unpaused now")); +} + +#[test] +fn remove_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-remove-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Remove, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected by remove"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn unpause_reports_upstream_style_invalid_state_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/not-paused.bin"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("unpause without paused state should be rejected"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("GID#{gid} cannot be unpaused now")); +} + +#[test] +fn remove_reports_upstream_style_missing_active_download_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000004".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2Remove, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("remove should reject unknown gid with upstream-style error"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("Active Download not found for GID#{gid}") + ); +} + +#[test] +fn force_pause_and_force_remove_return_gid_strings() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/force.bin"); + + let force_pause = dispatcher.dispatch_json(request( + RpcMethod::Aria2ForcePause, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(force_pause.result, Some(RpcValue::String(gid.clone()))); + + let force_remove = dispatcher.dispatch_json(request( + RpcMethod::Aria2ForceRemove, + vec![RpcValue::String(gid.clone())], + )); + assert_eq!(force_remove.result, Some(RpcValue::String(gid.clone()))); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/options_and_files.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/options_and_files.rs new file mode 100644 index 0000000..f154e38 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/options_and_files.rs @@ -0,0 +1,525 @@ +use super::*; + +#[test] +fn get_global_option_exposes_documented_default_keys() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetGlobalOption, vec![])); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("rpc-listen-port"), + Some(&RpcValue::String("6800".to_owned())) + ); + assert_eq!( + payload.get("max-overall-download-limit"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("retry-on-403"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + payload.get("ftp-pasv"), + Some(&RpcValue::String("true".to_owned())) + ); + assert_eq!( + payload.get("ftp-type"), + Some(&RpcValue::String("binary".to_owned())) + ); + assert_eq!( + payload.get("ftp-reuse-connection"), + Some(&RpcValue::String("true".to_owned())) + ); + assert_eq!( + payload.get("all-proxy-user"), + Some(&RpcValue::String(String::new())) + ); + } + other => panic!("unexpected getGlobalOption defaults result: {other:?}"), + } +} + +#[test] +fn per_download_options_round_trip_through_rpc() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([ + ("split".to_owned(), RpcValue::Number(8)), + ("out".to_owned(), RpcValue::String("file.iso".to_owned())), + ( + "ftp-proxy-user".to_owned(), + RpcValue::String("ftp-user".to_owned()), + ), + ("ftp-pasv".to_owned(), RpcValue::Bool(false)), + ])), + ], + )); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("split"), + Some(&RpcValue::String("8".to_owned())) + ); + assert_eq!( + payload.get("out"), + Some(&RpcValue::String("file.iso".to_owned())) + ); + assert_eq!( + payload.get("ftp-proxy-user"), + Some(&RpcValue::String("ftp-user".to_owned())) + ); + assert_eq!( + payload.get("ftp-pasv"), + Some(&RpcValue::String("false".to_owned())) + ); + } + other => panic!("unexpected getOption result: {other:?}"), + } +} + +#[test] +fn get_option_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000002".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("missing gid should be rejected by getOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Cannot get option for GID#{gid}")); +} + +#[test] +fn get_option_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-option-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected by getOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn get_files_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-files-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected by getFiles"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn change_option_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000003".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([("split".to_owned(), RpcValue::Number(8))])), + ], + )); + + let error = response + .error + .expect("missing gid should be rejected by changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Cannot change option for GID#{gid}")); +} + +#[test] +fn change_option_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-change-option-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([("split".to_owned(), RpcValue::Number(8))])), + ], + )); + + let error = response + .error + .expect("invalid gid should be rejected by changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn change_option_rejects_piece_length_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([( + "piece-length".to_owned(), + RpcValue::String("2M".to_owned()), + )])), + ], + )); + + let error = response + .error + .expect("piece-length should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("piece-length")); +} + +#[test] +fn change_option_rejects_pause_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([("pause".to_owned(), RpcValue::Bool(true))])), + ], + )); + + let error = response + .error + .expect("pause should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("pause")); +} + +#[test] +fn change_option_rejects_dry_run_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([( + "dry-run".to_owned(), + RpcValue::Bool(true), + )])), + ], + )); + + let error = response + .error + .expect("dry-run should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("dry-run")); +} + +#[test] +fn change_option_rejects_metalink_base_uri_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([( + "metalink-base-uri".to_owned(), + RpcValue::String("https://example.org/base/".to_owned()), + )])), + ], + )); + + let error = response + .error + .expect("metalink-base-uri should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("metalink-base-uri")); +} + +#[test] +fn change_option_rejects_parameterized_uri_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([( + "parameterized-uri".to_owned(), + RpcValue::Bool(true), + )])), + ], + )); + + let error = response + .error + .expect("parameterized-uri should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("parameterized-uri")); +} + +#[test] +fn change_option_rejects_rpc_save_upload_metadata_for_dynamic_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid), + RpcValue::Object(BTreeMap::from([( + "rpc-save-upload-metadata".to_owned(), + RpcValue::Bool(true), + )])), + ], + )); + + let error = response + .error + .expect("rpc-save-upload-metadata should be rejected for changeOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("rpc-save-upload-metadata")); +} + +#[test] +fn get_option_exposes_default_and_inherited_values() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-download-limit".to_owned(), + RpcValue::String("20K".to_owned()), + ), + ( + "all-proxy-user".to_owned(), + RpcValue::String("global-proxy-user".to_owned()), + ), + ]))], + )); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid)], + )); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("split"), + Some(&RpcValue::String("5".to_owned())) + ); + assert_eq!( + payload.get("continue"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + payload.get("max-download-limit"), + Some(&RpcValue::String("20K".to_owned())) + ); + assert_eq!( + payload.get("all-proxy-user"), + Some(&RpcValue::String("global-proxy-user".to_owned())) + ); + assert_eq!( + payload.get("ftp-pasv"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected getOption default/inherited result: {other:?}"), + } +} + +#[test] +fn uri_file_and_server_payloads_are_populated() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso"); + dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let uris = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid.clone())], + )); + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + let servers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + + match uris.result { + Some(RpcValue::Array(payload)) => { + assert_eq!(payload.len(), 1); + assert!(matches!(payload.first(), Some(RpcValue::Object(_)))); + } + other => panic!("unexpected getUris result: {other:?}"), + } + match files.result { + Some(RpcValue::Array(payload)) => { + assert_eq!(payload.len(), 1); + match payload.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("path"), + Some(&RpcValue::String("file.iso".to_owned())) + ); + } + other => panic!("unexpected getFiles payload: {other:?}"), + } + } + other => panic!("unexpected getFiles result: {other:?}"), + } + match servers.result { + Some(RpcValue::Array(payload)) => { + assert_eq!(payload.len(), 1); + assert!(matches!(payload.first(), Some(RpcValue::Object(_)))); + } + other => panic!("unexpected getServers result: {other:?}"), + } +} + +#[test] +fn get_servers_rejects_non_active_downloads_with_upstream_style_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + + match response.error { + Some(error) => { + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert!( + error + .message + .contains(&format!("No active download for GID#{gid}")) + ); + } + other => panic!("unexpected getServers non-active result: {other:?}"), + } +} + +#[test] +fn get_uris_files_and_peers_report_upstream_style_missing_gid_errors() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000bad".to_owned(); + + let get_uris = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid.clone())], + )); + let get_files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + let get_peers = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + + match get_uris.error { + Some(error) => { + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("No URI data is available for GID#{gid}") + ); + } + other => panic!("unexpected getUris missing-gid result: {other:?}"), + } + match get_files.error { + Some(error) => { + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("No file data is available for GID#{gid}") + ); + } + other => panic!("unexpected getFiles missing-gid result: {other:?}"), + } + match get_peers.error { + Some(error) => { + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("No peer data is available for GID#{gid}") + ); + } + other => panic!("unexpected getPeers missing-gid result: {other:?}"), + } +} + +#[test] +fn get_files_uses_dir_and_out_options_for_non_bt_path() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/downloads/source.bin"); + let group = dispatcher + .engine + .handle_mut(download_id(&gid)) + .expect("group should exist"); + group.set_option("dir", "D:/downloads"); + group.set_option("out", "renamed.iso"); + + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid)], + )); + let expected_path = std::path::PathBuf::from("D:/downloads") + .join("renamed.iso") + .to_string_lossy() + .into_owned(); + + match files.result { + Some(RpcValue::Array(payload)) => match payload.first() { + Some(RpcValue::Object(file)) => { + assert_eq!(file.get("path"), Some(&RpcValue::String(expected_path))); + } + other => panic!("unexpected getFiles payload: {other:?}"), + }, + other => panic!("unexpected getFiles result: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_and_uri.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_and_uri.rs new file mode 100644 index 0000000..72060dd --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_and_uri.rs @@ -0,0 +1,556 @@ +use super::*; + +#[test] +fn change_position_reorders_waiting_queue_and_returns_destination() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid0 = add_uri(&mut dispatcher, "https://example.org/0.iso"); + let gid1 = add_uri(&mut dispatcher, "https://example.org/1.iso"); + let gid2 = add_uri(&mut dispatcher, "https://example.org/2.iso"); + let gid3 = add_uri(&mut dispatcher, "https://example.org/3.iso"); + let gid4 = add_uri(&mut dispatcher, "https://example.org/4.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid1.clone()), + RpcValue::Number(4), + RpcValue::String("POS_SET".to_owned()), + ], + )); + assert_eq!(response.result, Some(RpcValue::Number(4))); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid2.clone()), + RpcValue::Number(3), + RpcValue::String("POS_SET".to_owned()), + ], + )); + assert_eq!(response.result, Some(RpcValue::Number(3))); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid2.clone()), + RpcValue::Number(1), + RpcValue::String("POS_SET".to_owned()), + ], + )); + assert_eq!(response.result, Some(RpcValue::Number(1))); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid1.clone()), + RpcValue::Number(1), + RpcValue::String("POS_CUR".to_owned()), + ], + )); + assert_eq!(response.result, Some(RpcValue::Number(4))); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid0.clone()), + RpcValue::Number(-2), + RpcValue::String("POS_END".to_owned()), + ], + )); + assert_eq!(response.result, Some(RpcValue::Number(2))); + + let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![])); + let waiting_gids = match waiting.result { + Some(RpcValue::Array(entries)) => entries + .into_iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("gid") { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected waiting payload: {other:?}"), + }, + other => panic!("unexpected waiting row: {other:?}"), + }) + .collect::>(), + other => panic!("unexpected tellWaiting result: {other:?}"), + }; + assert_eq!(waiting_gids, vec![gid2, gid3, gid0, gid4, gid1]); +} + +#[test] +fn change_position_rejects_active_downloads_not_in_waiting_queue() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/active.iso"); + let _ = dispatcher.engine.schedule_once(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(0), + RpcValue::String("POS_SET".to_owned()), + ], + )); + let error = response + .error + .expect("active gid should not be movable in waiting queue"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("GID#{gid} not found in the waiting queue.") + ); +} + +#[test] +fn change_position_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-position-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(0), + RpcValue::String("POS_SET".to_owned()), + ], + )); + let error = response + .error + .expect("invalid gid should be rejected by changePosition"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn change_position_reports_upstream_style_missing_waiting_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "00000000000000aa".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangePosition, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(0), + RpcValue::String("POS_SET".to_owned()), + ], + )); + let error = response + .error + .expect("missing gid should be rejected by changePosition"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("GID#{gid} not found in the waiting queue.") + ); +} + +#[test] +fn get_uris_reports_used_and_waiting_entries_in_order() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddUri, + vec![RpcValue::Array(vec![ + RpcValue::String("https://example.org/primary.iso".to_owned()), + RpcValue::String("https://mirror1.example.org/primary.iso".to_owned()), + RpcValue::String("https://mirror2.example.org/primary.iso".to_owned()), + ])], + )); + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid)], + )); + + match response.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 3); + let tuples = entries + .into_iter() + .map(|entry| match entry { + RpcValue::Object(payload) => { + let status = match payload.get("status") { + Some(RpcValue::String(value)) => value.clone(), + other => panic!("unexpected uri status: {other:?}"), + }; + let uri = match payload.get("uri") { + Some(RpcValue::String(value)) => value.clone(), + other => panic!("unexpected uri value: {other:?}"), + }; + (status, uri) + } + other => panic!("unexpected getUris row: {other:?}"), + }) + .collect::>(); + assert_eq!( + tuples, + vec![ + ( + "used".to_owned(), + "https://example.org/primary.iso".to_owned(), + ), + ( + "waiting".to_owned(), + "https://mirror1.example.org/primary.iso".to_owned(), + ), + ( + "waiting".to_owned(), + "https://mirror2.example.org/primary.iso".to_owned(), + ), + ] + ); + } + other => panic!("unexpected getUris result: {other:?}"), + } +} + +#[test] +fn get_uris_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-uris-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected by getUris"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn change_uri_removes_and_inserts_uris_with_position_semantics() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2AddUri, + vec![RpcValue::Array(vec![ + RpcValue::String("https://example.org/base.iso".to_owned()), + RpcValue::String("https://mirror1.example.org/base.iso".to_owned()), + RpcValue::String("https://mirror2.example.org/base.iso".to_owned()), + ])], + )); + let gid = match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeUri, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(1), + RpcValue::Array(vec![RpcValue::String( + "https://mirror1.example.org/base.iso".to_owned(), + )]), + RpcValue::Array(vec![ + RpcValue::String("baduri".to_owned()), + RpcValue::String("https://mirror3.example.org/base.iso".to_owned()), + RpcValue::String("https://mirror4.example.org/base.iso".to_owned()), + ]), + RpcValue::Number(1), + ], + )); + assert_eq!( + response.result, + Some(RpcValue::Array(vec![ + RpcValue::Number(1), + RpcValue::Number(2), + ])) + ); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid)], + )); + match response.result { + Some(RpcValue::Array(entries)) => { + let uris = entries + .into_iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("uri") { + Some(RpcValue::String(uri)) => uri.clone(), + other => panic!("unexpected uri payload: {other:?}"), + }, + other => panic!("unexpected getUris row: {other:?}"), + }) + .collect::>(); + assert_eq!( + uris, + vec![ + "https://example.org/base.iso".to_owned(), + "https://mirror3.example.org/base.iso".to_owned(), + "https://mirror4.example.org/base.iso".to_owned(), + "https://mirror2.example.org/base.iso".to_owned(), + ] + ); + } + other => panic!("unexpected getUris result after changeUri: {other:?}"), + } +} + +#[test] +fn change_uri_rejects_out_of_range_file_index() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeUri, + vec![ + RpcValue::String(gid), + RpcValue::Number(2), + RpcValue::Array(Vec::new()), + RpcValue::Array(Vec::new()), + ], + )); + let error = response + .error + .expect("out-of-range fileIndex should be rejected"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert!(error.message.contains("fileIndex is out of range")); +} + +#[test] +fn change_uri_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let missing_gid = "0123456789abcdef".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeUri, + vec![ + RpcValue::String(missing_gid.clone()), + RpcValue::Number(1), + RpcValue::Array(Vec::new()), + RpcValue::Array(Vec::new()), + ], + )); + let error = response + .error + .expect("missing gid should be rejected by changeUri"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!( + error.message, + format!("Cannot remove URIs from GID#{missing_gid}") + ); +} + +#[test] +fn change_uri_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-change-uri-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeUri, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(1), + RpcValue::Array(Vec::new()), + RpcValue::Array(Vec::new()), + ], + )); + let error = response + .error + .expect("invalid gid should be rejected by changeUri"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn change_uri_skips_non_string_entries_in_uri_arrays() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeUri, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Number(1), + RpcValue::Array(vec![ + RpcValue::Number(1), + RpcValue::Bool(false), + RpcValue::String("https://example.org/file.iso".to_owned()), + ]), + RpcValue::Array(vec![ + RpcValue::Object(BTreeMap::new()), + RpcValue::String("baduri".to_owned()), + RpcValue::String("https://mirror.example.org/file.iso".to_owned()), + RpcValue::Number(2), + RpcValue::String("https://mirror2.example.org/file.iso".to_owned()), + ]), + RpcValue::Number(0), + ], + )); + assert_eq!( + response.result, + Some(RpcValue::Array(vec![ + RpcValue::Number(1), + RpcValue::Number(2), + ])) + ); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetUris, + vec![RpcValue::String(gid)], + )); + match response.result { + Some(RpcValue::Array(entries)) => { + let uris = entries + .into_iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("uri") { + Some(RpcValue::String(uri)) => uri.clone(), + other => panic!("unexpected uri payload: {other:?}"), + }, + other => panic!("unexpected getUris row: {other:?}"), + }) + .collect::>(); + assert_eq!( + uris, + vec![ + "https://mirror.example.org/file.iso".to_owned(), + "https://mirror2.example.org/file.iso".to_owned(), + ] + ); + } + other => panic!("unexpected getUris result after mixed changeUri arrays: {other:?}"), + } +} + +#[test] +fn purge_download_result_removes_only_stopped_downloads() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + let paused_gid = add_uri(&mut dispatcher, "https://example.org/paused.iso"); + let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso"); + let removed_gid = add_uri(&mut dispatcher, "https://example.org/removed.iso"); + let error_gid = add_uri(&mut dispatcher, "https://example.org/error.iso"); + + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(paused_gid.clone())], + )); + dispatcher + .engine + .complete(download_id(&complete_gid)) + .expect("complete transition should succeed"); + dispatcher + .engine + .remove(download_id(&removed_gid)) + .expect("remove transition should succeed"); + dispatcher + .engine + .fail(download_id(&error_gid)) + .expect("error transition should succeed"); + + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2PurgeDownloadResult, vec![])); + assert_eq!(response.result, Some(RpcValue::String("OK".to_owned()))); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&waiting_gid)) + .is_some() + ); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&paused_gid)) + .is_some() + ); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&complete_gid)) + .is_none() + ); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&removed_gid)) + .is_none() + ); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&error_gid)) + .is_none() + ); +} + +#[test] +fn remove_download_result_removes_stopped_gid_and_preserves_live_queue() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso"); + dispatcher + .engine + .complete(download_id(&complete_gid)) + .expect("complete transition should succeed"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2RemoveDownloadResult, + vec![RpcValue::String(complete_gid.clone())], + )); + assert_eq!(response.result, Some(RpcValue::String("OK".to_owned()))); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&complete_gid)) + .is_none() + ); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&waiting_gid)) + .is_some() + ); +} + +#[test] +fn remove_download_result_rejects_live_gid() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2RemoveDownloadResult, + vec![RpcValue::String(waiting_gid.clone())], + )); + let error = response + .error + .expect("live gid should not be removable via removeDownloadResult"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert!(error.message.contains(&waiting_gid)); + assert!( + dispatcher + .engine + .registry() + .get(download_id(&waiting_gid)) + .is_some() + ); +} + +#[test] +fn remove_download_result_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-remove-result-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2RemoveDownloadResult, + vec![RpcValue::String(gid.clone())], + )); + let error = response + .error + .expect("invalid gid should be rejected by removeDownloadResult"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer.rs new file mode 100644 index 0000000..8347036 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer.rs @@ -0,0 +1,7 @@ +pub(super) use super::*; + +mod file_views; +mod queue_mutation; +mod queue_views; +mod session_and_shutdown; +mod transfer_runtime; diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/file_views.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/file_views.rs new file mode 100644 index 0000000..91a7353 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/file_views.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn get_files_exposes_piece_bitfield_and_piece_metrics() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/pieces.bin"); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Downloading); + group.set_completed_length(1_536); + + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + match files.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("pieceLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + file.get("numPieces"), + Some(&RpcValue::String("4".to_owned())) + ); + assert_eq!( + file.get("bitfield"), + Some(&RpcValue::String("2100".to_owned())) + ); + assert_eq!( + file.get("completedLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + } + other => panic!("unexpected file payload entry: {other:?}"), + }, + other => panic!("unexpected getFiles result for bitfield test: {other:?}"), + } +} + +#[test] +fn get_files_completed_length_counts_only_verified_pieces_for_bt_files() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/bt-layout.bin"); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_piece_length(1024); + group.set_total_length(4096); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_piece_state(PieceId(1), PieceState::Downloading); + group.set_completed_length(1_536); + let bt = BtRuntimeState { + files: vec![ + BtFileInfo { + path: "disc-1.mkv".to_owned(), + length: 2048, + piece_offset: Some(0), + selected: true, + }, + BtFileInfo { + path: "disc-2.mkv".to_owned(), + length: 2048, + piece_offset: Some(2048), + selected: true, + }, + ], + ..BtRuntimeState::default() + }; + group.set_bt(bt); + + let files = dispatcher.dispatch_json(request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + match files.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 2); + let first = match &entries[0] { + RpcValue::Object(file) => file, + other => panic!("unexpected first file row: {other:?}"), + }; + let second = match &entries[1] { + RpcValue::Object(file) => file, + other => panic!("unexpected second file row: {other:?}"), + }; + assert_eq!( + first.get("completedLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + second.get("completedLength"), + Some(&RpcValue::String("0".to_owned())) + ); + } + other => panic!("unexpected getFiles result for bt completedLength test: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_mutation.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_mutation.rs new file mode 100644 index 0000000..1fff195 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_mutation.rs @@ -0,0 +1,155 @@ +use super::*; + +#[test] +fn pause_all_and_unpause_all_mutate_queue_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid1 = add_uri(&mut dispatcher, "https://example.org/a.iso"); + let gid2 = add_uri(&mut dispatcher, "https://example.org/b.iso"); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid1.clone())], + )); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(gid2.clone())], + )); + + let paused = dispatcher.dispatch_json(request(RpcMethod::Aria2PauseAll, vec![])); + assert_eq!(paused.result, Some(RpcValue::String("OK".to_owned()))); + + let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![])); + match waiting.result { + Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 2), + other => panic!("unexpected tellWaiting result after pauseAll: {other:?}"), + } + + let stopped = dispatcher.dispatch_json(request(RpcMethod::Aria2TellStopped, vec![])); + match stopped.result { + Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 0), + other => panic!("unexpected tellStopped result after pauseAll: {other:?}"), + } + + let resumed = dispatcher.dispatch_json(request(RpcMethod::Aria2UnpauseAll, vec![])); + assert_eq!(resumed.result, Some(RpcValue::String("OK".to_owned()))); + + let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![])); + match waiting.result { + Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 2), + other => panic!("unexpected tellWaiting result after unpauseAll: {other:?}"), + } +} + +#[test] +fn pause_all_and_unpause_all_only_touch_documented_statuses() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + let active_gid = add_uri(&mut dispatcher, "https://example.org/active.iso"); + let paused_gid = add_uri(&mut dispatcher, "https://example.org/paused.iso"); + let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso"); + let removed_gid = add_uri(&mut dispatcher, "https://example.org/removed.iso"); + + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Unpause, + vec![RpcValue::String(active_gid.clone())], + )); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(paused_gid.clone())], + )); + dispatcher + .engine + .complete(download_id(&complete_gid)) + .expect("complete transition should succeed"); + dispatcher + .engine + .remove(download_id(&removed_gid)) + .expect("remove transition should succeed"); + + let paused = dispatcher.dispatch_json(request(RpcMethod::Aria2PauseAll, vec![])); + assert_eq!(paused.result, Some(RpcValue::String("OK".to_owned()))); + + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&waiting_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Paused) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&active_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Paused) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&paused_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Paused) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&complete_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Complete) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&removed_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Removed) + ); + + let resumed = dispatcher.dispatch_json(request(RpcMethod::Aria2UnpauseAll, vec![])); + assert_eq!(resumed.result, Some(RpcValue::String("OK".to_owned()))); + + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&waiting_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Waiting) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&active_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Waiting) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&paused_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Waiting) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&complete_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Complete) + ); + assert_eq!( + dispatcher + .engine + .registry() + .get(download_id(&removed_gid)) + .map(|group| group.status().clone()), + Some(aria2_rust_pro_core::DownloadStatus::Removed) + ); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_views.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_views.rs new file mode 100644 index 0000000..c7ea91a --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_views.rs @@ -0,0 +1,291 @@ +use super::*; + +#[test] +fn tell_waiting_and_tell_stopped_respect_offset_and_max() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid1 = add_uri(&mut dispatcher, "https://example.org/1.iso"); + let gid2 = add_uri(&mut dispatcher, "https://example.org/2.iso"); + let gid3 = add_uri(&mut dispatcher, "https://example.org/3.iso"); + + let waiting_forward = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellWaiting, + vec![RpcValue::Number(0), RpcValue::Number(10)], + )); + let forward_waiting_gids = match waiting_forward.result { + Some(RpcValue::Array(entries)) => entries + .iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("gid") { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected waiting gid payload: {other:?}"), + }, + other => panic!("unexpected waiting row: {other:?}"), + }) + .collect::>(), + other => panic!("unexpected tellWaiting forward result: {other:?}"), + }; + + let waiting_slice = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellWaiting, + vec![RpcValue::Number(1), RpcValue::Number(1)], + )); + match waiting_slice.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 1); + match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("gid"), + Some(&RpcValue::String(forward_waiting_gids[1].clone())) + ); + } + other => panic!("unexpected tellWaiting row: {other:?}"), + } + } + other => panic!("unexpected tellWaiting slice result: {other:?}"), + } + + let _ = dispatcher.dispatch_json(request(RpcMethod::Aria2Pause, vec![RpcValue::String(gid1)])); + let _ = dispatcher.dispatch_json(request(RpcMethod::Aria2Pause, vec![RpcValue::String(gid2)])); + dispatcher + .engine + .complete(download_id(&gid3)) + .expect("complete transition should succeed"); + + let stopped_slice = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStopped, + vec![RpcValue::Number(0), RpcValue::Number(2)], + )); + match stopped_slice.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 1); + match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid3.clone()))); + } + other => panic!("unexpected tellStopped row: {other:?}"), + } + } + other => panic!("unexpected tellStopped slice result: {other:?}"), + } +} + +#[test] +fn tell_active_filters_requested_keys_only() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/active.iso"); + let _ = dispatcher.engine.schedule_once(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellActive, + vec![RpcValue::Array(vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + ])], + )); + + match response.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.len(), 2); + assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid))); + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("active".to_owned())) + ); + } + other => panic!("unexpected tellActive filtered row: {other:?}"), + }, + other => panic!("unexpected tellActive filtered result: {other:?}"), + } +} + +#[test] +fn tell_waiting_and_tell_stopped_filter_requested_keys_only() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso"); + let stopped_gid = add_uri(&mut dispatcher, "https://example.org/stopped.iso"); + dispatcher + .engine + .complete(download_id(&stopped_gid)) + .expect("complete transition should succeed"); + + let waiting = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellWaiting, + vec![ + RpcValue::Number(0), + RpcValue::Number(10), + RpcValue::Array(vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + ]), + ], + )); + match waiting.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.len(), 2); + assert_eq!(payload.get("gid"), Some(&RpcValue::String(waiting_gid))); + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("waiting".to_owned())) + ); + } + other => panic!("unexpected tellWaiting filtered row: {other:?}"), + }, + other => panic!("unexpected tellWaiting filtered result: {other:?}"), + } + + let stopped = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStopped, + vec![ + RpcValue::Number(0), + RpcValue::Number(10), + RpcValue::Array(vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + ]), + ], + )); + match stopped.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.len(), 2); + assert_eq!(payload.get("gid"), Some(&RpcValue::String(stopped_gid))); + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + } + other => panic!("unexpected tellStopped filtered row: {other:?}"), + }, + other => panic!("unexpected tellStopped filtered result: {other:?}"), + } +} + +#[test] +fn tell_waiting_and_tell_stopped_support_negative_offsets() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let _gid1 = add_uri(&mut dispatcher, "https://example.org/a.iso"); + let gid2 = add_uri(&mut dispatcher, "https://example.org/b.iso"); + let gid3 = add_uri(&mut dispatcher, "https://example.org/c.iso"); + + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2Pause, + vec![RpcValue::String(gid2.clone())], + )); + dispatcher + .engine + .complete(download_id(&gid3)) + .expect("complete transition should succeed"); + + let waiting_forward = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellWaiting, + vec![RpcValue::Number(0), RpcValue::Number(10)], + )); + let forward_waiting_gids = match waiting_forward.result { + Some(RpcValue::Array(entries)) => entries + .iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("gid") { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected waiting gid payload: {other:?}"), + }, + other => panic!("unexpected waiting row: {other:?}"), + }) + .collect::>(), + other => panic!("unexpected tellWaiting forward result: {other:?}"), + }; + + let waiting_tail = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellWaiting, + vec![RpcValue::Number(-1), RpcValue::Number(2)], + )); + match waiting_tail.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 2); + let tail_gids = entries + .iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("gid") { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected waiting gid payload: {other:?}"), + }, + other => panic!("unexpected waiting row: {other:?}"), + }) + .collect::>(); + let expected = forward_waiting_gids + .iter() + .rev() + .take(2) + .cloned() + .collect::>(); + assert_eq!(tail_gids, expected); + } + other => panic!("unexpected tellWaiting negative-offset result: {other:?}"), + } + + let stopped_tail = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStopped, + vec![RpcValue::Number(-1), RpcValue::Number(1)], + )); + match stopped_tail.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 1); + match entries.first() { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid3.clone()))); + } + other => panic!("unexpected tellStopped negative-offset row: {other:?}"), + } + } + other => panic!("unexpected tellStopped negative-offset result: {other:?}"), + } +} + +#[test] +fn tell_stopped_orders_by_least_recently_stopped_first() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid_a = add_uri(&mut dispatcher, "https://example.org/a.iso"); + let gid_b = add_uri(&mut dispatcher, "https://example.org/b.iso"); + let gid_c = add_uri(&mut dispatcher, "https://example.org/c.iso"); + let gid_d = add_uri(&mut dispatcher, "https://example.org/d.iso"); + + dispatcher + .engine + .complete(download_id(&gid_c)) + .expect("complete transition should succeed"); + dispatcher + .engine + .remove(download_id(&gid_a)) + .expect("remove transition should succeed"); + dispatcher + .engine + .complete(download_id(&gid_d)) + .expect("complete transition should succeed"); + dispatcher + .engine + .fail(download_id(&gid_b)) + .expect("error transition should succeed"); + + let stopped = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStopped, + vec![RpcValue::Number(0), RpcValue::Number(10)], + )); + + let gids = match stopped.result { + Some(RpcValue::Array(entries)) => entries + .iter() + .map(|entry| match entry { + RpcValue::Object(payload) => match payload.get("gid") { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected tellStopped gid payload: {other:?}"), + }, + other => panic!("unexpected tellStopped row: {other:?}"), + }) + .collect::>(), + other => panic!("unexpected tellStopped result: {other:?}"), + }; + + assert_eq!(gids, vec![gid_c, gid_a, gid_d, gid_b]); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/session_and_shutdown.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/session_and_shutdown.rs new file mode 100644 index 0000000..be9995e --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/session_and_shutdown.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn save_session_uses_runtime_session_path() { + let session_path = temp_session_path("rpc-session.txt"); + let runtime = RuntimeConfig::default().with_session_path(session_path.clone()); + let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime); + let _gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso"); + + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2SaveSession, vec![])); + assert_eq!(response.result, Some(RpcValue::String("OK".to_owned()))); + + let session = load_session_file(&session_path).expect("saved session file should load"); + assert_eq!(session.entries.len(), 1); + + let root = session_path + .parent() + .expect("session path should have a parent") + .to_path_buf(); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn shutdown_methods_return_ok() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let shutdown = dispatcher.dispatch_json(request(RpcMethod::Aria2Shutdown, vec![])); + assert_eq!(shutdown.result, Some(RpcValue::String("OK".to_owned()))); + + let force = dispatcher.dispatch_json(request(RpcMethod::Aria2ForceShutdown, vec![])); + assert_eq!(force.result, Some(RpcValue::String("OK".to_owned()))); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/transfer_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/transfer_runtime.rs new file mode 100644 index 0000000..dc400a9 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/transfer_runtime.rs @@ -0,0 +1,464 @@ +use super::*; + +#[test] +fn tell_status_and_global_stat_reflect_piece_backed_progress() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/progress.bin"); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_piece_length(1024); + group.set_total_length(2048); + group.set_piece_state(PieceId(0), PieceState::Verified); + group.set_download_speed(256); + group.set_num_connections(2); + group.set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("totalLength"), + Some(&RpcValue::String("2048".to_owned())) + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("2".to_owned())) + ); + assert_eq!( + payload.get("activeSegments"), + Some(&RpcValue::String("2".to_owned())) + ); + } + other => panic!("unexpected tellStatus progress result: {other:?}"), + } + + let global = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![])); + match global.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numActive"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numWaiting"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String("256".to_owned())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("numStopped"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!(payload.len(), 6); + } + other => panic!("unexpected tellGlobalStat progress result: {other:?}"), + } +} + +#[test] +fn record_http_transfer_result_updates_rpc_visible_lengths() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/fixture.bin"); + let response = aria2_rust_pro_protocol::HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { + headers: vec![aria2_rust_pro_protocol::HttpHeader { + name: "content-length".to_owned(), + value: "4096".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }], + }, + body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 4096]), + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + dispatcher + .record_http_transfer_result(&gid, &response, 4, 2, true) + .expect("http result should update dispatcher state"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + assert_eq!( + payload.get("totalLength"), + Some(&RpcValue::String("4096".to_owned())) + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("4096".to_owned())) + ); + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("4".to_owned())) + ); + assert_eq!( + payload.get("retryCount"), + Some(&RpcValue::String("2".to_owned())) + ); + } + other => panic!("unexpected tellStatus after http result: {other:?}"), + } +} + +#[test] +fn record_http_transfer_result_respects_terminal_completion_gate() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/checksum-gated.bin"); + let response = aria2_rust_pro_protocol::HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { + headers: vec![aria2_rust_pro_protocol::HttpHeader { + name: "content-length".to_owned(), + value: "4096".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }], + }, + body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 4096]), + content_range: None, + partial_content: false, + checksum: Some(aria2_rust_pro_protocol::ChecksumSpec { + algorithm: "sha-1".to_owned(), + expected_hex: "0000000000000000000000000000000000000000".to_owned(), + actual_hex: None, + }), + redirected_from: None, + }; + + dispatcher + .record_http_transfer_result(&gid, &response, 4, 0, false) + .expect("gated http result should update dispatcher state without completion"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("active".to_owned())) + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("4096".to_owned())) + ); + } + other => panic!("unexpected tellStatus after gated http result: {other:?}"), + } +} + +#[test] +fn record_http_transfer_result_keeps_partial_transfer_active() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/partial.bin"); + let response = aria2_rust_pro_protocol::HttpResponseModel { + status: 206, + reason: "Partial Content".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { + headers: vec![ + aria2_rust_pro_protocol::HttpHeader { + name: "content-length".to_owned(), + value: "1024".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + aria2_rust_pro_protocol::HttpHeader { + name: "content-range".to_owned(), + value: "bytes 0-1023/4096".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + ], + }, + body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]), + content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec { + unit: aria2_rust_pro_protocol::RangeUnit::Bytes, + start: 0, + end_inclusive: 1023, + total_size: Some(4096), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + + dispatcher + .record_http_transfer_result(&gid, &response, 2, 1, true) + .expect("partial http result should be ingested"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("active".to_owned())) + ); + assert_eq!( + payload.get("totalLength"), + Some(&RpcValue::String("4096".to_owned())) + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("1024".to_owned())) + ); + assert_eq!( + payload.get("retryCount"), + Some(&RpcValue::String("1".to_owned())) + ); + } + other => panic!("unexpected tellStatus after partial http result: {other:?}"), + } +} + +#[test] +fn record_http_transfer_result_recovers_piece_prefix_when_state_lags_completed_length() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/partial-prefix.bin"); + let first_response = aria2_rust_pro_protocol::HttpResponseModel { + status: 206, + reason: "Partial Content".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { + headers: vec![ + aria2_rust_pro_protocol::HttpHeader { + name: "content-length".to_owned(), + value: "1024".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + aria2_rust_pro_protocol::HttpHeader { + name: "content-range".to_owned(), + value: "bytes 0-1023/4096".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + ], + }, + body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]), + content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec { + unit: aria2_rust_pro_protocol::RangeUnit::Bytes, + start: 0, + end_inclusive: 1023, + total_size: Some(4096), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + dispatcher + .record_http_transfer_result(&gid, &first_response, 2, 0, true) + .expect("first partial response should be ingested"); + + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_piece_length(1024); + group.set_piece_state(PieceId(0), PieceState::Pending); + + let second_response = aria2_rust_pro_protocol::HttpResponseModel { + status: 206, + reason: "Partial Content".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { + headers: vec![ + aria2_rust_pro_protocol::HttpHeader { + name: "content-length".to_owned(), + value: "1024".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + aria2_rust_pro_protocol::HttpHeader { + name: "content-range".to_owned(), + value: "bytes 1024-2047/4096".to_owned(), + kind: aria2_rust_pro_protocol::HeaderKind::Response, + }, + ], + }, + body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]), + content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec { + unit: aria2_rust_pro_protocol::RangeUnit::Bytes, + start: 1024, + end_inclusive: 2047, + total_size: Some(4096), + unsatisfied: false, + }), + partial_content: true, + checksum: None, + redirected_from: None, + }; + dispatcher + .record_http_transfer_result(&gid, &second_response, 2, 0, true) + .expect("second partial response should be ingested"); + + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should still exist"); + assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Verified)); + assert_eq!(group.piece_state(PieceId(1)), Some(PieceState::Verified)); + assert_eq!(group.completed_length(), 2048); +} + +#[test] +fn record_http_transfer_result_marks_retry_relevant_failure_waiting() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/retry.bin"); + let response = aria2_rust_pro_protocol::HttpResponseModel { + status: 503, + reason: "Service Unavailable".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { headers: vec![] }, + body: aria2_rust_pro_protocol::ResponseBody::Empty, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + dispatcher + .record_http_transfer_result(&gid, &response, 1, 3, true) + .expect("retry-relevant failure should be ingested"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("waiting".to_owned())) + ); + assert_eq!( + payload.get("retryCount"), + Some(&RpcValue::String("3".to_owned())) + ); + } + other => panic!("unexpected tellStatus after retry-relevant failure: {other:?}"), + } +} + +#[test] +fn record_http_transfer_result_marks_non_retry_failure_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/not-found.bin"); + let response = aria2_rust_pro_protocol::HttpResponseModel { + status: 404, + reason: "Not Found".to_owned(), + version: aria2_rust_pro_protocol::HttpVersion::Http11, + headers: aria2_rust_pro_protocol::HttpResponseHeaders { headers: vec![] }, + body: aria2_rust_pro_protocol::ResponseBody::Empty, + content_range: None, + partial_content: false, + checksum: None, + redirected_from: None, + }; + + dispatcher + .record_http_transfer_result(&gid, &response, 1, 0, true) + .expect("error failure should be ingested"); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("error".to_owned())) + ); + } + other => panic!("unexpected tellStatus after non-retry failure: {other:?}"), + } +} + +#[test] +fn tell_status_exposes_retry_attempts_and_resume_state() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/retry-telemetry.bin"); + let group = dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist"); + group.set_retry_count(2); + group.push_retry_attempt(aria2_rust_pro_core::RetryAttempt { + attempt: 1, + offset: 1024, + length: Some(2048), + error: Some("connection reset".to_owned()), + recoverable: true, + }); + group.push_retry_attempt(aria2_rust_pro_core::RetryAttempt { + attempt: 2, + offset: 4096, + length: None, + error: Some("timeout".to_owned()), + recoverable: true, + }); + group.set_resume_state(aria2_rust_pro_core::ResumeState { + persisted: true, + resume_offset: 4096, + validated_length: Some(2048), + segment_cursor: Some(PieceId(4)), + }); + + let status = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match status.result { + Some(RpcValue::Object(payload)) => { + assert!(matches!( + payload.get("retryAttempts"), + Some(RpcValue::Array(attempts)) if attempts.len() == 2 + )); + assert_eq!( + payload.get("activeSegments"), + Some(&RpcValue::String("0".to_owned())) + ); + assert!(matches!( + payload.get("resumeState"), + Some(RpcValue::Object(state)) + if state.get("resumeOffset") + == Some(&RpcValue::String("4096".to_owned())) + )); + } + other => panic!("unexpected tellStatus retry telemetry result: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/status_and_global.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/status_and_global.rs new file mode 100644 index 0000000..f023cd8 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/status_and_global.rs @@ -0,0 +1,324 @@ +use super::*; + +#[test] +fn tell_status_returns_object_payload() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("waiting".to_owned())) + ); + assert!(payload.contains_key("files")); + assert_eq!(payload.get("gid"), payload.get("gid")); + } + other => panic!("unexpected tellStatus result: {other:?}"), + } +} + +#[test] +fn tell_status_reports_upstream_style_missing_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "0000000000000001".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("missing gid should be rejected by tellStatus"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("No such download for GID#{gid}")); +} + +#[test] +fn tell_status_reports_upstream_style_invalid_gid_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "not-a-gid".to_owned(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + + let error = response + .error + .expect("invalid gid should be rejected by tellStatus"); + assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError); + assert_eq!(error.message, format!("Invalid GID {gid}")); +} + +#[test] +fn tell_status_filters_requested_keys_only() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Array(vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + ]), + ], + )); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.len(), 2); + assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid))); + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("waiting".to_owned())) + ); + } + other => panic!("unexpected tellStatus filtered result: {other:?}"), + } +} + +#[test] +fn tell_status_with_empty_keys_keeps_full_payload() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid), RpcValue::Array(Vec::new())], + )); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert!(payload.contains_key("gid")); + assert!(payload.contains_key("status")); + assert!(payload.contains_key("files")); + } + other => panic!("unexpected tellStatus empty-keys result: {other:?}"), + } +} + +#[test] +fn tell_global_stat_reflects_queue_counts() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_uri(&mut dispatcher, "https://example.org/file.iso"); + dispatcher + .engine + .handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse")) + .expect("group should exist") + .set_status(aria2_rust_pro_core::DownloadStatus::Active); + + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![])); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numActive"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numWaiting"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("numStopped"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("0".to_owned())) + ); + assert_eq!(payload.len(), 6); + } + other => panic!("unexpected tellGlobalStat result: {other:?}"), + } +} + +#[test] +fn get_global_stat_is_public_method_name_and_legacy_alias_is_hidden() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request_with_method_name("system.listMethods", vec![])); + match response.result { + Some(RpcValue::Array(methods)) => { + let methods = methods + .into_iter() + .map(|value| match value { + RpcValue::String(method) => method, + other => panic!("unexpected method entry: {other:?}"), + }) + .collect::>(); + assert!(methods.iter().any(|method| method == "aria2.getGlobalStat")); + assert!( + !methods + .iter() + .any(|method| method == "aria2.tellGlobalStat") + ); + } + other => panic!("unexpected system.listMethods result: {other:?}"), + } +} + +#[test] +fn legacy_tell_global_stat_alias_still_dispatches() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = + dispatcher.dispatch_json(request_with_method_name("aria2.tellGlobalStat", vec![])); + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("numStoppedTotal"), + Some(&RpcValue::String("0".to_owned())) + ); + } + other => panic!("unexpected legacy tellGlobalStat alias result: {other:?}"), + } +} + +#[test] +fn global_options_round_trip_through_rpc() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let _ = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-connection-per-server".to_owned(), + RpcValue::String("32".to_owned()), + ), + ("retry-on-403".to_owned(), RpcValue::Bool(true)), + ( + "all-proxy-user".to_owned(), + RpcValue::String("proxy-user".to_owned()), + ), + ("ftp-pasv".to_owned(), RpcValue::Bool(false)), + ]))], + )); + + let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetGlobalOption, vec![])); + + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("max-connection-per-server"), + Some(&RpcValue::String("32".to_owned())) + ); + assert_eq!( + payload.get("retry-on-403"), + Some(&RpcValue::String("true".to_owned())) + ); + assert_eq!( + payload.get("all-proxy-user"), + Some(&RpcValue::String("proxy-user".to_owned())) + ); + assert_eq!( + payload.get("ftp-pasv"), + Some(&RpcValue::String("false".to_owned())) + ); + } + other => panic!("unexpected getGlobalOption result: {other:?}"), + } +} + +#[test] +fn change_global_option_rejects_checksum() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "checksum".to_owned(), + RpcValue::String("sha-1=deadbeef".to_owned()), + )]))], + )); + + let error = response + .error + .expect("checksum should be rejected for changeGlobalOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("checksum")); +} + +#[test] +fn change_global_option_rejects_out() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "out".to_owned(), + RpcValue::String("file.iso".to_owned()), + )]))], + )); + + let error = response + .error + .expect("out should be rejected for changeGlobalOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("out")); +} + +#[test] +fn change_global_option_rejects_index_out() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "index-out".to_owned(), + RpcValue::String("1=disc1.iso".to_owned()), + )]))], + )); + + let error = response + .error + .expect("index-out should be rejected for changeGlobalOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("index-out")); +} + +#[test] +fn change_global_option_rejects_pause() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "pause".to_owned(), + RpcValue::Bool(true), + )]))], + )); + + let error = response + .error + .expect("pause should be rejected for changeGlobalOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("pause")); +} + +#[test] +fn change_global_option_rejects_select_file() { + let mut dispatcher = InProcessRpcDispatcher::new(); + + let response = dispatcher.dispatch_json(request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("1,2".to_owned()), + )]))], + )); + + let error = response + .error + .expect("select-file should be rejected for changeGlobalOption"); + assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams); + assert!(error.message.contains("select-file")); +} diff --git a/crates/aria2-rust-pro-rpc/src/dispatcher/transfer_runtime.rs b/crates/aria2-rust-pro-rpc/src/dispatcher/transfer_runtime.rs new file mode 100644 index 0000000..2bc43e3 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/dispatcher/transfer_runtime.rs @@ -0,0 +1,454 @@ +use aria2_rust_pro_core::{DownloadStatus, PieceId, PieceState, RequestContext, RequestGroup}; +use aria2_rust_pro_protocol::{ + HttpResponseModel, magnet::parse_magnet_bootstrap, metalink::metalink_download_plan, + parse_metalink_document, parse_torrent_metadata, +}; +use base64::Engine; + +use crate::{ + jsonrpc::{JsonRpcRequest, JsonRpcResponse}, + model::{RpcError, RpcValue}, +}; + +use super::{ + InProcessRpcDispatcher, build_bt_runtime_state, build_bt_runtime_state_from_magnet, + helpers::{ + apply_group_options, apply_group_string_options, decode_metalink_payload, + is_retry_relevant_status, metalink_default_options, parse_optional_option_object, + parse_optional_position, parse_optional_uri_array, parse_uri_list_param, u32_from_usize, + usize_from_u64, + }, + http_response_completed_length, http_response_delta_length, http_response_length, + missing_download_error, parse_gid_text, +}; + +impl InProcessRpcDispatcher { + /// Registers URI-style downloads directly without routing through the JSON-RPC surface. + /// + /// # Errors + /// + /// Returns an error when the supplied URI list is empty or a magnet URI is invalid. + pub fn add_uri_direct( + &mut self, + uris: Vec, + options: Vec<(String, RpcValue)>, + ) -> Result { + let string_options = options + .into_iter() + .filter_map(|(key, value)| match value { + RpcValue::String(value) => Some((key, value)), + RpcValue::Number(value) => Some((key, value.to_string())), + RpcValue::Bool(value) => { + Some((key, if value { "true" } else { "false" }.to_owned())) + } + RpcValue::Null => Some((key, String::new())), + RpcValue::Array(_) | RpcValue::Object(_) => None, + }) + .collect::>(); + self.add_uri_direct_string_options(uris, string_options) + } + + /// Registers URI-style downloads directly with already-normalized string options. + /// + /// # Errors + /// + /// Returns an error when the supplied URI list is empty or a magnet URI is invalid. + /// + /// # Panics + /// + /// Panics only if the single-URI branch observes the checked URI list as empty. + pub fn add_uri_direct_string_options( + &mut self, + uris: Vec, + options: Vec<(String, String)>, + ) -> Result { + let uri = uris + .first() + .ok_or_else(|| RpcError::invalid_params("aria2.addUri needs at least one uri"))?; + if uri + .get(.."magnet:?".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("magnet:?")) + { + let parsed = parse_magnet_bootstrap(uri).map_err(|error| { + RpcError::invalid_params(&format!("invalid magnet uri: {error}")) + })?; + let bt_state = build_bt_runtime_state_from_magnet(uri, &parsed); + let primary_uri = bt_state + .trackers + .first() + .map(|tracker| tracker.url.clone()) + .unwrap_or_else(|| uri.clone()); + let mut context = RequestContext::new(primary_uri); + context.source = Some("magnet".to_owned()); + context.note = bt_state.name.clone(); + let gid = self.engine.add_request(context).gid(); + if let Some(group) = self.engine.handle_mut(gid) { + group.set_bt(bt_state); + apply_group_string_options(group, options); + } + return Ok(gid.to_string()); + } + + if uris.len() == 1 { + let uri = uris + .into_iter() + .next() + .expect("checked single URI should remain present"); + let gid = self.engine.add_uri(uri); + if let Some(group) = self.engine.handle_mut(gid.gid()) { + apply_group_string_options(group, options); + } + return Ok(gid.gid().to_string()); + } + + let uri = uri.clone(); + let mut context = RequestContext::new(uri); + context.replace_uris(uris); + let gid = self.engine.add_request(context); + if let Some(group) = self.engine.handle_mut(gid.gid()) { + apply_group_string_options(group, options); + } + Ok(gid.gid().to_string()) + } + + /// Marks a tracked download as complete. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the download is no longer tracked. + pub fn mark_complete(&mut self, gid: &str) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + self.engine + .complete(gid) + .map_err(|_| missing_download_error(gid)) + } + + /// Prepares an HTTP download for an outbound transfer attempt. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the download is no longer tracked. + pub fn prepare_http_download(&mut self, gid: &str) -> Result { + let gid = parse_gid_text(gid)?; + self.engine + .prepare_http_download(gid) + .map_err(|_| missing_download_error(gid)) + } + + /// Records the result of an HTTP transfer back into the engine state. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the download is no longer tracked. + pub fn record_http_transfer_result( + &mut self, + gid: &str, + response: &HttpResponseModel, + max_connections: u16, + retry_count: u32, + allow_terminal_complete: bool, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + let piece_length = self.engine.runtime().piece_length.max(1); + let response_total_length = http_response_length(response).unwrap_or(0); + let response_completed_length = http_response_completed_length(response).unwrap_or(0); + let response_delta = + http_response_delta_length(response).unwrap_or(response_completed_length); + let next_status = { + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + let previous_completed_length = group.completed_length(); + let total_length = group.total_length().max(response_total_length); + let completed_length = previous_completed_length.max(response_completed_length); + let piece_count = if total_length == 0 { + 0_usize + } else { + usize_from_u64(total_length.div_ceil(piece_length)) + }; + let previous_verified_piece_count = + usize_from_u64(previous_completed_length / piece_length).min(piece_count); + let verified_piece_start = if previous_verified_piece_count == 0 + || group.piece_state(PieceId(u32_from_usize( + previous_verified_piece_count.saturating_sub(1), + ))) == Some(PieceState::Verified) + { + previous_verified_piece_count + } else { + 0 + }; + let should_complete = allow_terminal_complete + && matches!(response.status, 200..=299) + && total_length > 0 + && completed_length >= total_length; + let verified_piece_count = if should_complete { + piece_count + } else { + usize_from_u64(completed_length / piece_length) + } + .min(piece_count); + let next_status = if should_complete { + DownloadStatus::Complete + } else if is_retry_relevant_status(response.status) { + DownloadStatus::Waiting + } else if matches!(response.status, 200..=299) + || (completed_length > 0 && completed_length < total_length) + { + DownloadStatus::Active + } else { + DownloadStatus::Error + }; + group.set_piece_length(piece_length); + if total_length > 0 { + group.set_total_length(total_length); + } + group.set_completed_length(completed_length); + group.set_retry_count(retry_count); + group.set_num_connections(u32::from(max_connections)); + group.set_download_speed(response_delta); + if matches!(response.status, 200..=299) && verified_piece_count > verified_piece_start { + for piece_index in verified_piece_start..verified_piece_count { + group.set_piece_state( + PieceId(u32_from_usize(piece_index)), + PieceState::Verified, + ); + } + } + group.set_status(next_status); + next_status + }; + if next_status == DownloadStatus::Complete { + self.engine + .complete(gid) + .map_err(|_| missing_download_error(gid)) + } else { + Ok(()) + } + } + + /// Records a generic transport transfer result back into the engine state. + /// + /// # Errors + /// + /// Returns an error when `gid` is invalid or the download is no longer tracked. + pub fn record_transfer_result( + &mut self, + gid: &str, + total_length: u64, + completed_length: u64, + max_connections: u16, + success: bool, + retry_count: u32, + ) -> Result<(), RpcError> { + let gid = parse_gid_text(gid)?; + let piece_length = self.engine.runtime().piece_length.max(1); + let piece_count = if total_length == 0 { + 0_usize + } else { + usize_from_u64(total_length.div_ceil(piece_length)) + }; + { + let group = self + .engine + .handle_mut(gid) + .ok_or_else(|| missing_download_error(gid))?; + group.set_piece_length(piece_length); + group.set_total_length(total_length); + group.set_completed_length(completed_length); + group.set_retry_count(retry_count); + group.set_num_connections(u32::from(max_connections)); + group.set_download_speed(completed_length); + if success { + for piece_index in 0..piece_count { + group.set_piece_state( + PieceId(u32_from_usize(piece_index)), + PieceState::Verified, + ); + } + } + } + if success { + self.engine + .complete(gid) + .map_err(|_| missing_download_error(gid)) + } else { + self.engine + .fail(gid) + .map_err(|_| missing_download_error(gid)) + } + } + + /// Handles `aria2.addUri` by validating RPC parameters and registering URI downloads. + pub(crate) fn handle_add_uri(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let Some(first) = request.params.first() else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.addUri needs at least one uri"), + ); + }; + let uris = match parse_uri_list_param(first) { + Ok(uris) => uris, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + let options = match parse_optional_option_object(request.params.get(1), "aria2.addUri") { + Ok(options) => options, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + if let Err(error) = parse_optional_position(request.params.get(2), "aria2.addUri") { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + match self.add_uri_direct(uris, options) { + Ok(gid) => JsonRpcResponse::success(request.id, RpcValue::String(gid)), + Err(error) => JsonRpcResponse::error(request.id, error), + } + } + + /// Handles `aria2.addTorrent` by decoding torrent metadata and registering BT downloads. + pub(crate) fn handle_add_torrent(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let Some(first) = request.params.first() else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.addTorrent needs torrent payload"), + ); + }; + if let Some(param) = request.params.get(1) + && let Err(error) = parse_optional_uri_array(param, "aria2.addTorrent") + { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + let options = match parse_optional_option_object(request.params.get(2), "aria2.addTorrent") + { + Ok(options) => options, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + if let Err(error) = parse_optional_position(request.params.get(3), "aria2.addTorrent") { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + let encoded = match first { + RpcValue::String(payload) => payload.clone(), + _ => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("torrent payload must be base64 string"), + ); + } + }; + let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) { + Ok(bytes) => bytes, + Err(error) => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!("invalid torrent base64 payload: {error}")), + ); + } + }; + let metadata = match parse_torrent_metadata(&bytes) { + Ok(metadata) => metadata, + Err(error) => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!("invalid torrent metadata: {error}")), + ); + } + }; + let bt_state = build_bt_runtime_state(&metadata); + let primary_uri = bt_state + .trackers + .first() + .map(|tracker| tracker.url.clone()) + .or_else(|| bt_state.magnet_uri.clone()) + .unwrap_or_else(|| format!("bittorrent://{}", bt_state.info_hash)); + let mut context = RequestContext::new(primary_uri); + context.source = Some("torrent".to_owned()); + context.note = bt_state.name.clone(); + let gid = self.engine.add_request(context).gid(); + if let Some(group) = self.engine.handle_mut(gid) { + group.set_bt(bt_state); + group.set_total_length(metadata.total_length()); + group.set_piece_length(metadata.info.piece_length); + for piece in &metadata.pieces { + group.set_piece_state(PieceId(piece.index), PieceState::Pending); + } + apply_group_options(group, options); + } + JsonRpcResponse::success(request.id, RpcValue::String(gid.to_string())) + } + + /// Handles `aria2.addMetalink` by expanding actionable metalink files into downloads. + /// + /// # Panics + /// + /// Panics only if a metalink download plan entry contains no URI after plan validation. + pub(crate) fn handle_add_metalink(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let Some(first) = request.params.first() else { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("aria2.addMetalink needs metalink xml text"), + ); + }; + let options = match parse_optional_option_object(request.params.get(1), "aria2.addMetalink") + { + Ok(options) => options, + Err(error) => { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + }; + if let Err(error) = parse_optional_position(request.params.get(2), "aria2.addMetalink") { + return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error)); + } + let metalink_text = match first { + RpcValue::String(text) => decode_metalink_payload(text).unwrap_or_else(|| text.clone()), + _ => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("metalink payload must be string xml text"), + ); + } + }; + let document = match parse_metalink_document(&metalink_text) { + Ok(document) => document, + Err(error) => { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params(&format!("invalid metalink: {error}")), + ); + } + }; + let plan = metalink_download_plan(&document); + if plan.is_empty() { + return JsonRpcResponse::error( + request.id, + RpcError::invalid_params("metalink document contains no usable resource url"), + ); + } + + let gids = plan + .into_iter() + .map(|entry| { + let mut context = RequestContext::new( + entry + .uris + .first() + .cloned() + .expect("metalink download plan should contain at least one uri"), + ); + context.replace_uris(entry.uris.clone()); + let gid = self.engine.add_request(context).gid(); + if let Some(group) = self.engine.handle_mut(gid) { + apply_group_options(group, metalink_default_options(&entry)); + apply_group_options(group, options.clone()); + } + RpcValue::String(gid.to_string()) + }) + .collect::>(); + + JsonRpcResponse::success(request.id, RpcValue::Array(gids)) + } +} diff --git a/crates/aria2-rust-pro-rpc/src/handlers.rs b/crates/aria2-rust-pro-rpc/src/handlers.rs new file mode 100644 index 0000000..956895d --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/handlers.rs @@ -0,0 +1,641 @@ +//! Method validation and compatibility-oriented RPC handler stubs. +#![expect( + clippy::needless_pass_by_value, + reason = "handler signatures intentionally mirror transport payloads and centralized compat wording" +)] + +use std::collections::BTreeMap; + +use aria2_rust_pro_compat::version_line; + +use crate::{ + jsonrpc::{ + JsonRpcNotification, JsonRpcRequest, SYNTHETIC_INVALID_PARAMS_METHOD, + SYNTHETIC_INVALID_REQUEST_METHOD, + }, + methods::{ + RpcMethod, is_required_rpc_method, rpc_method, rpc_method_names, rpc_notification_names, + }, + model::{RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcResultEnvelope, RpcValue}, + xmlrpc::{ + XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, rpc_value_to_xmlrpc, + xmlrpc_value_to_rpc, + }, +}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// Context passed into handler execution. +pub struct RpcHandlerContext { + /// Authentication state derived from the active transport. + pub auth: crate::model::RpcAuthContext, + /// Per-request metadata propagated through the stack. + pub meta: RpcMeta, +} + +#[derive(Debug, Clone, Copy, Default)] +/// Registry of compatibility-oriented RPC handlers. +pub struct RpcHandlerRegistry; + +impl RpcHandlerRegistry { + #[must_use] + /// Handles a JSON-RPC request and returns a normalized envelope. + pub fn handle_json(self, request: JsonRpcRequest, ctx: RpcHandlerContext) -> RpcResultEnvelope { + let _ = self; + let _ = ctx; + let synthetic_error_message = request + .params + .first() + .and_then(|value| match value { + RpcValue::String(message) => Some(message.as_str()), + _ => None, + }) + .unwrap_or("Invalid Request."); + match request.method.as_str() { + SYNTHETIC_INVALID_REQUEST_METHOD => RpcResultEnvelope { + result: None, + error: Some(RpcError { + code: RpcErrorCode::InvalidRequest, + kind: RpcErrorKind::InvalidParams, + message: synthetic_error_message.to_owned(), + }), + }, + SYNTHETIC_INVALID_PARAMS_METHOD => RpcResultEnvelope { + result: None, + error: Some(RpcError::invalid_params(synthetic_error_message)), + }, + _ => Self::handle_method_request(request), + } + } + + /// Routes a resolved method name through the compatibility stub table. + fn handle_method_request(request: JsonRpcRequest) -> RpcResultEnvelope { + let Some(method) = rpc_method(&request.method) else { + return unknown_or_stubbed_method(&request.method); + }; + + if let Some(error) = validate_method_params(method, &request.params) { + return error_envelope(error); + } + + compatibility_response(method) + } + + /// Handles a JSON-RPC notification. + pub fn handle_notification(self, _notification: JsonRpcNotification, _ctx: RpcHandlerContext) { + let _ = self; + } + + #[must_use] + /// Handles an XML-RPC method call by reusing the JSON handler path. + pub fn handle_xml( + self, + request: XmlRpcMethodCall, + ctx: RpcHandlerContext, + ) -> XmlRpcMethodResponse { + let meta = ctx.meta.clone(); + let envelope = self.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: request.method_name, + params: request + .params + .into_iter() + .map(|p| xmlrpc_value_to_rpc(p.value)) + .collect(), + meta, + }, + ctx, + ); + + match envelope.error { + Some(error) => XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: 1, + message: error.message.clone(), + error: Some(error), + }), + meta: RpcMeta::default(), + }, + None => XmlRpcMethodResponse { + value: envelope.result.map(rpc_value_to_xmlrpc), + fault: None, + meta: RpcMeta::default(), + }, + } + } +} + +/// Produces the canned compatibility response for a resolved RPC method. +fn compatibility_response(method: RpcMethod) -> RpcResultEnvelope { + match method { + RpcMethod::Aria2AddUri + | RpcMethod::Aria2AddTorrent + | RpcMethod::Aria2AddMetalink + | RpcMethod::Aria2ChangeGlobalOption + | RpcMethod::Aria2ChangeOption + | RpcMethod::Aria2SaveSession + | RpcMethod::Aria2Shutdown + | RpcMethod::Aria2ForceShutdown => success_envelope(RpcValue::Null), + RpcMethod::Aria2Remove + | RpcMethod::Aria2ForceRemove + | RpcMethod::Aria2Pause + | RpcMethod::Aria2PauseAll + | RpcMethod::Aria2ForcePause + | RpcMethod::Aria2ForcePauseAll + | RpcMethod::Aria2Unpause + | RpcMethod::Aria2UnpauseAll => success_envelope(RpcValue::Bool(true)), + RpcMethod::Aria2TellStatus + | RpcMethod::Aria2TellGlobalStat + | RpcMethod::Aria2GetGlobalOption + | RpcMethod::Aria2GetOption + | RpcMethod::Aria2GetSessionInfo => success_envelope(RpcValue::Object(BTreeMap::new())), + RpcMethod::Aria2TellActive + | RpcMethod::Aria2TellWaiting + | RpcMethod::Aria2TellStopped + | RpcMethod::Aria2GetUris + | RpcMethod::Aria2GetFiles + | RpcMethod::Aria2GetPeers + | RpcMethod::Aria2GetServers + | RpcMethod::SystemMulticall => success_envelope(RpcValue::Array(Vec::new())), + RpcMethod::Aria2ChangeUri => success_envelope(RpcValue::Array(vec![ + RpcValue::Number(0), + RpcValue::Number(0), + ])), + RpcMethod::Aria2GetVersion => success_envelope(RpcValue::Object(BTreeMap::from([ + ("version".to_owned(), RpcValue::String(version_line())), + ("rpcVersion".to_owned(), RpcValue::String("2.0".to_owned())), + ]))), + RpcMethod::SystemListMethods => success_envelope(RpcValue::Array( + rpc_method_names() + .into_iter() + .map(|name| RpcValue::String(name.to_owned())) + .collect(), + )), + RpcMethod::SystemListNotifications => success_envelope(RpcValue::Array( + rpc_notification_names() + .into_iter() + .map(|name| RpcValue::String(name.to_owned())) + .collect(), + )), + _ => unknown_or_stubbed_method(method.as_str()), + } +} + +/// Wraps a successful result payload in a normalized handler envelope. +const fn success_envelope(result: RpcValue) -> RpcResultEnvelope { + RpcResultEnvelope { + result: Some(result), + error: None, + } +} + +/// Wraps an error payload in a normalized handler envelope. +const fn error_envelope(error: RpcError) -> RpcResultEnvelope { + RpcResultEnvelope { + result: None, + error: Some(error), + } +} + +/// Distinguishes required-but-stubbed methods from truly unknown compatibility names. +fn unknown_or_stubbed_method(method: &str) -> RpcResultEnvelope { + if is_required_rpc_method(method) { + error_envelope(RpcError::unsupported("rpc method stubbed")) + } else { + error_envelope(RpcError::unknown_method(method)) + } +} + +/// Validates the documented positional parameter contract for compatibility methods. +fn validate_method_params(method: RpcMethod, params: &[RpcValue]) -> Option { + match method { + RpcMethod::SystemListMethods if !params.is_empty() => Some(RpcError::invalid_params( + "system.listMethods takes no parameters", + )), + RpcMethod::SystemListNotifications if !params.is_empty() => Some(RpcError::invalid_params( + "system.listNotifications takes no parameters", + )), + RpcMethod::Aria2GetVersion if !params.is_empty() => Some(RpcError::invalid_params( + "aria2.getVersion takes no parameters", + )), + RpcMethod::Aria2GetSessionInfo if !params.is_empty() => Some(RpcError::invalid_params( + "aria2.getSessionInfo takes no parameters", + )), + RpcMethod::Aria2TellGlobalStat if !params.is_empty() => Some(RpcError::invalid_params( + "aria2.getGlobalStat takes no parameters", + )), + RpcMethod::SystemMulticall if params.is_empty() => Some(RpcError::invalid_params( + "system.multicall requires method specs", + )), + RpcMethod::SystemMulticall if !matches!(params.first(), Some(RpcValue::Array(_))) => Some( + RpcError::invalid_params("system.multicall expected array of method specs"), + ), + _ => None, + } +} + +#[cfg(test)] +/// Unit tests for transport-neutral RPC handler behavior. +mod tests { + use super::*; + use crate::{ + model::RpcAuthContext, + xmlrpc::{XmlRpcMember, XmlRpcParam, XmlRpcValue, xmlrpc_value_to_rpc}, + }; + + /// Builds a default handler context for unit tests. + fn ctx() -> RpcHandlerContext { + RpcHandlerContext { + auth: RpcAuthContext::default(), + meta: RpcMeta::default(), + } + } + + #[test] + /// Verifies that unknown `XML-RPC` methods return a shared fault payload. + fn handle_xml_unknown_method_returns_fault_with_error_payload() { + let registry = RpcHandlerRegistry; + let response = registry.handle_xml( + XmlRpcMethodCall { + method_name: "aria2.notFound".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert!(response.value.is_none()); + let fault = response.fault.expect("expected fault"); + assert_eq!(fault.code, 1); + let expected = RpcError::unknown_method("aria2.notFound"); + assert_eq!(fault.message, expected.message); + assert_eq!(fault.error, Some(expected)); + } + + #[test] + /// Verifies that nested `XML-RPC` values convert with the shared compatibility rules. + fn xml_param_conversion_handles_nested_values_with_shared_rules() { + let nested = XmlRpcValue::Struct(vec![XmlRpcMember { + name: "outer".to_owned(), + value: XmlRpcValue::Array(vec![ + XmlRpcValue::Double(3.25), + XmlRpcValue::Base64(vec![0x41, 0x42]), + XmlRpcValue::Struct(vec![XmlRpcMember { + name: "inner".to_owned(), + value: XmlRpcValue::Bool(true), + }]), + ]), + }]); + + let expected = RpcValue::Object(BTreeMap::from([( + "outer".to_owned(), + RpcValue::Array(vec![ + RpcValue::String("3.25".to_owned()), + RpcValue::String("QUI=".to_owned()), + RpcValue::Object(BTreeMap::from([("inner".to_owned(), RpcValue::Bool(true))])), + ]), + )])); + + assert_eq!(xmlrpc_value_to_rpc(nested), expected); + } + + #[test] + /// Verifies that nested array and struct parameters are accepted for `XML-RPC` addUri. + fn handle_xml_accepts_nested_struct_and_array_params() { + let registry = RpcHandlerRegistry; + let response = registry.handle_xml( + XmlRpcMethodCall { + method_name: "aria2.addUri".to_owned(), + params: vec![XmlRpcParam { + value: XmlRpcValue::Array(vec![XmlRpcValue::Struct(vec![XmlRpcMember { + name: "k".to_owned(), + value: XmlRpcValue::Array(vec![XmlRpcValue::Int(1), XmlRpcValue::Nil]), + }])]), + }], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert!(response.fault.is_none()); + assert!(matches!(response.value, Some(XmlRpcValue::Nil))); + } + + #[test] + /// Verifies that `system.listMethods` keeps the upstream public method ordering. + fn handle_json_list_methods_matches_upstream_order() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "system.listMethods".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ctx(), + ); + + match response.result { + Some(RpcValue::Array(items)) => { + let names = items + .into_iter() + .map(|item| match item { + RpcValue::String(name) => name, + other => panic!("unexpected method item: {other:?}"), + }) + .collect::>(); + assert_eq!( + names, + vec![ + "aria2.addUri".to_owned(), + "aria2.addTorrent".to_owned(), + "aria2.getPeers".to_owned(), + "aria2.addMetalink".to_owned(), + "aria2.remove".to_owned(), + "aria2.pause".to_owned(), + "aria2.forcePause".to_owned(), + "aria2.pauseAll".to_owned(), + "aria2.forcePauseAll".to_owned(), + "aria2.unpause".to_owned(), + "aria2.unpauseAll".to_owned(), + "aria2.forceRemove".to_owned(), + "aria2.changePosition".to_owned(), + "aria2.tellStatus".to_owned(), + "aria2.getUris".to_owned(), + "aria2.getFiles".to_owned(), + "aria2.getServers".to_owned(), + "aria2.tellActive".to_owned(), + "aria2.tellWaiting".to_owned(), + "aria2.tellStopped".to_owned(), + "aria2.getOption".to_owned(), + "aria2.changeUri".to_owned(), + "aria2.changeOption".to_owned(), + "aria2.getGlobalOption".to_owned(), + "aria2.changeGlobalOption".to_owned(), + "aria2.purgeDownloadResult".to_owned(), + "aria2.removeDownloadResult".to_owned(), + "aria2.getVersion".to_owned(), + "aria2.getSessionInfo".to_owned(), + "aria2.shutdown".to_owned(), + "aria2.forceShutdown".to_owned(), + "aria2.getGlobalStat".to_owned(), + "aria2.saveSession".to_owned(), + "system.multicall".to_owned(), + "system.listMethods".to_owned(), + "system.listNotifications".to_owned(), + ] + ); + } + other => panic!("unexpected listMethods result: {other:?}"), + } + } + + #[test] + /// Verifies that `system.listNotifications` returns the upstream aria2 names. + fn handle_json_list_notifications_matches_upstream_aria2_names() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "system.listNotifications".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ctx(), + ); + + match response.result { + Some(RpcValue::Array(items)) => { + let names = items + .into_iter() + .map(|item| match item { + RpcValue::String(name) => name, + other => panic!("unexpected notification item: {other:?}"), + }) + .collect::>(); + assert_eq!( + names, + vec![ + "aria2.onDownloadStart".to_owned(), + "aria2.onDownloadPause".to_owned(), + "aria2.onDownloadStop".to_owned(), + "aria2.onDownloadComplete".to_owned(), + "aria2.onDownloadError".to_owned(), + "aria2.onBtDownloadComplete".to_owned(), + ] + ); + } + other => panic!("unexpected listNotifications result: {other:?}"), + } + } + + #[test] + /// Verifies that `system.listMethods` rejects unexpected parameters. + fn handle_json_list_methods_rejects_unexpected_params() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "system.listMethods".to_owned(), + params: vec![RpcValue::Bool(true)], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert_eq!( + response.error, + Some(RpcError::invalid_params( + "system.listMethods takes no parameters" + )) + ); + } + + #[test] + /// Verifies that `system.listNotifications` rejects unexpected parameters. + fn handle_json_list_notifications_rejects_unexpected_params() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "system.listNotifications".to_owned(), + params: vec![RpcValue::Bool(true)], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert_eq!( + response.error, + Some(RpcError::invalid_params( + "system.listNotifications takes no parameters" + )) + ); + } + + #[test] + /// Verifies that `aria2.getVersion` rejects unexpected parameters. + fn handle_json_get_version_rejects_unexpected_params() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "aria2.getVersion".to_owned(), + params: vec![RpcValue::String("token:abc".to_owned())], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert_eq!( + response.error, + Some(RpcError::invalid_params( + "aria2.getVersion takes no parameters" + )) + ); + } + + #[test] + /// Verifies that `system.multicall` rejects a missing method-spec array. + fn handle_json_multicall_rejects_missing_method_specs() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "system.multicall".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert_eq!( + response.error, + Some(RpcError::invalid_params( + "system.multicall requires method specs" + )) + ); + } + + #[test] + /// Verifies that the legacy multicall alias still enforces array-shaped method specs. + fn handle_json_legacy_multicall_alias_rejects_non_array_method_specs() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: "aria2.multicall".to_owned(), + params: vec![RpcValue::Bool(true)], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert_eq!( + response.error, + Some(RpcError::invalid_params( + "system.multicall expected array of method specs" + )) + ); + } + + #[test] + /// Verifies that the synthetic invalid-request marker becomes a `JSON-RPC` invalid-request error. + fn handle_json_synthetic_invalid_request_returns_jsonrpc_invalid_request_error() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: Some(crate::jsonrpc::JsonRpcId::Null), + method: SYNTHETIC_INVALID_REQUEST_METHOD.to_owned(), + params: vec![RpcValue::String("Invalid Request.".to_owned())], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert!(response.result.is_none()); + assert_eq!( + response.error, + Some(RpcError { + code: RpcErrorCode::InvalidRequest, + kind: RpcErrorKind::InvalidParams, + message: "Invalid Request.".to_owned(), + }) + ); + } + + #[test] + /// Verifies that the synthetic invalid-params marker becomes a `JSON-RPC` invalid-params error. + fn handle_json_synthetic_invalid_params_returns_jsonrpc_invalid_params_error() { + let registry = RpcHandlerRegistry; + let response = registry.handle_json( + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: Some(crate::jsonrpc::JsonRpcId::String("q2".to_owned())), + method: SYNTHETIC_INVALID_PARAMS_METHOD.to_owned(), + params: vec![RpcValue::String("Invalid params.".to_owned())], + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert!(response.result.is_none()); + assert_eq!( + response.error, + Some(RpcError { + code: RpcErrorCode::InvalidParams, + kind: RpcErrorKind::InvalidParams, + message: "Invalid params.".to_owned(), + }) + ); + } + + #[test] + /// Verifies that `XML-RPC` listNotifications returns the prefixed aria2 event names. + fn handle_xml_list_notifications_returns_prefixed_names() { + let registry = RpcHandlerRegistry; + let response = registry.handle_xml( + XmlRpcMethodCall { + method_name: "system.listNotifications".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ctx(), + ); + + assert!(response.fault.is_none()); + match response.value { + Some(XmlRpcValue::Array(items)) => { + let names = items + .into_iter() + .map(|item| match item { + XmlRpcValue::String(name) => name, + other => panic!("unexpected XML notification item: {other:?}"), + }) + .collect::>(); + assert_eq!( + names, + vec![ + "aria2.onDownloadStart".to_owned(), + "aria2.onDownloadPause".to_owned(), + "aria2.onDownloadStop".to_owned(), + "aria2.onDownloadComplete".to_owned(), + "aria2.onDownloadError".to_owned(), + "aria2.onBtDownloadComplete".to_owned(), + ] + ); + } + other => panic!("unexpected XML listNotifications result: {other:?}"), + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/jsonrpc.rs b/crates/aria2-rust-pro-rpc/src/jsonrpc.rs new file mode 100644 index 0000000..717522d --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/jsonrpc.rs @@ -0,0 +1,547 @@ +//! JSON-RPC request and response types plus serde helpers. +#![expect( + clippy::redundant_pub_crate, + reason = "JSON-RPC model type names stay intentionally explicit for transport parity" +)] + +use std::collections::BTreeMap; + +use serde_json::{Map, Number, Value}; + +use crate::model::{RpcError, RpcMeta, RpcValue}; + +/// Synthetic method marker used when malformed payloads need an invalid-request envelope. +pub(super) const SYNTHETIC_INVALID_REQUEST_METHOD: &str = "__aria2_rust_pro.invalid_request__"; +/// Synthetic method marker used when malformed payloads need an invalid-params envelope. +pub(super) const SYNTHETIC_INVALID_PARAMS_METHOD: &str = "__aria2_rust_pro.invalid_params__"; + +#[derive(Debug, Clone, PartialEq)] +/// JSON-RPC id values accepted by the compatibility surface. +pub enum JsonRpcId { + /// Explicit JSON `null` id. + Null, + /// Boolean id. + Bool(bool), + /// Integer id. + Number(i64), + /// String id. + String(String), + /// Structured array id retained for compatibility. + Array(Vec), + /// Structured object id retained for compatibility. + Object(BTreeMap), +} + +#[derive(Debug, Clone, PartialEq)] +/// Canonical JSON-RPC request model used by the dispatcher. +pub struct JsonRpcRequest { + /// Advertised JSON-RPC version. + pub jsonrpc: Option, + /// Request identifier, if one was provided. + pub id: Option, + /// Requested method name. + pub method: String, + /// Positional parameters. + pub params: Vec, + /// Supplemental per-request metadata. + pub meta: RpcMeta, +} + +impl JsonRpcRequest { + #[must_use] + /// Creates an empty JSON-RPC 2.0 request for the given method name. + pub fn new(method: impl Into) -> Self { + Self { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.into(), + params: Vec::new(), + meta: RpcMeta::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Canonical JSON-RPC notification model. +pub struct JsonRpcNotification { + /// Advertised JSON-RPC version. + pub jsonrpc: Option, + /// Notification method name. + pub method: String, + /// Positional parameters. + pub params: Vec, + /// Supplemental per-request metadata. + pub meta: RpcMeta, +} + +#[derive(Debug, Clone, PartialEq)] +/// Canonical JSON-RPC response model. +pub struct JsonRpcResponse { + /// Advertised JSON-RPC version. + pub jsonrpc: Option, + /// Echoed request identifier. + pub id: Option, + /// Successful result payload, if any. + pub result: Option, + /// Error payload, if the request failed. + pub error: Option, + /// Supplemental metadata attached to the response. + pub meta: RpcMeta, +} + +impl JsonRpcResponse { + #[must_use] + /// Creates a successful JSON-RPC response. + pub fn success(id: Option, value: RpcValue) -> Self { + Self { + jsonrpc: Some("2.0".to_owned()), + id, + result: Some(value), + error: None, + meta: RpcMeta::default(), + } + } + + #[must_use] + /// Creates an error JSON-RPC response. + pub fn error(id: Option, error: RpcError) -> Self { + Self { + jsonrpc: Some("2.0".to_owned()), + id, + result: None, + error: Some(error), + meta: RpcMeta::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Parsed top-level JSON-RPC payload. +pub(super) enum JsonRpcPayload { + /// Single request payload. + Single(JsonRpcRequest), + /// Batch payload retaining per-item parse failures. + Batch(Vec>), +} + +/// Converts a transport-neutral RPC value into a `serde_json::Value`. +fn rpc_value_to_json(value: &RpcValue) -> Value { + match value { + RpcValue::Null => Value::Null, + RpcValue::Bool(value) => Value::Bool(*value), + RpcValue::Number(value) => Value::Number(Number::from(*value)), + RpcValue::String(value) => Value::String(value.clone()), + RpcValue::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()), + RpcValue::Object(values) => Value::Object( + values + .iter() + .map(|(key, value)| (key.clone(), rpc_value_to_json(value))) + .collect::>(), + ), + } +} + +/// Converts a parsed JSON value into the transport-neutral RPC value tree. +fn json_value_to_rpc(value: Value) -> RpcValue { + match value { + Value::Null => RpcValue::Null, + Value::Bool(value) => RpcValue::Bool(value), + Value::Number(value) => RpcValue::Number(value.as_i64().unwrap_or_default()), + Value::String(value) => RpcValue::String(value), + Value::Array(values) => { + RpcValue::Array(values.into_iter().map(json_value_to_rpc).collect()) + } + Value::Object(values) => RpcValue::Object( + values + .into_iter() + .map(|(key, value)| (key, json_value_to_rpc(value))) + .collect(), + ), + } +} + +/// Parses an `id` member into the compatibility-layer `JsonRpcId` shape. +fn parse_jsonrpc_id(value: &Value) -> Option { + match value { + Value::Null => Some(JsonRpcId::Null), + Value::Bool(value) => Some(JsonRpcId::Bool(*value)), + Value::Number(value) => value.as_i64().map(JsonRpcId::Number), + Value::String(value) => Some(JsonRpcId::String(value.clone())), + Value::Array(values) => Some(JsonRpcId::Array( + values.iter().cloned().map(json_value_to_rpc).collect(), + )), + Value::Object(values) => Some(JsonRpcId::Object( + values + .iter() + .map(|(key, value)| (key.clone(), json_value_to_rpc(value.clone()))) + .collect(), + )), + } +} + +/// Converts an internal JSON-RPC id back into `serde_json` form for rendering. +fn jsonrpc_id_to_json(id: &JsonRpcId) -> Value { + match id { + JsonRpcId::Null => Value::Null, + JsonRpcId::Bool(value) => Value::Bool(*value), + JsonRpcId::Number(value) => Value::Number(Number::from(*value)), + JsonRpcId::String(value) => Value::String(value.clone()), + JsonRpcId::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()), + JsonRpcId::Object(values) => Value::Object( + values + .iter() + .map(|(key, value)| (key.clone(), rpc_value_to_json(value))) + .collect(), + ), + } +} + +/// Builds a synthetic request carrying a parse-time protocol error. +fn synthetic_error_request( + method: &'static str, + id: Option, + message: impl Into, + jsonrpc: Option, +) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc, + id, + method: method.to_owned(), + params: vec![RpcValue::String(message.into())], + meta: RpcMeta::default(), + } +} + +/// Builds the canonical invalid-request synthetic request wrapper. +fn invalid_request(id: Option, jsonrpc: Option) -> JsonRpcRequest { + synthetic_error_request( + SYNTHETIC_INVALID_REQUEST_METHOD, + id, + "Invalid Request.", + jsonrpc, + ) +} + +/// Builds the canonical invalid-parameters synthetic request wrapper. +fn invalid_params(id: Option, jsonrpc: Option) -> JsonRpcRequest { + synthetic_error_request( + SYNTHETIC_INVALID_PARAMS_METHOD, + id, + "Invalid params.", + jsonrpc, + ) +} + +/// Parses a request object while normalizing malformed members into synthetic requests. +fn jsonrpc_request_from_object(mut object: Map) -> JsonRpcRequest { + let has_id = object.contains_key("id"); + let id = object.get("id").and_then(parse_jsonrpc_id); + let jsonrpc = object + .get("jsonrpc") + .and_then(Value::as_str) + .map(str::to_owned); + if !has_id { + return invalid_request(Some(JsonRpcId::Null), jsonrpc); + } + let Some(id) = id else { + return invalid_request(Some(JsonRpcId::Null), jsonrpc); + }; + let method = object + .get("method") + .and_then(Value::as_str) + .map(str::to_owned); + let Some(method) = method else { + return invalid_request(Some(id), jsonrpc); + }; + let params = match object.remove("params") { + Some(Value::Array(values)) => values.into_iter().map(json_value_to_rpc).collect(), + Some(_) => return invalid_params(Some(id), jsonrpc), + None => Vec::new(), + }; + JsonRpcRequest { + jsonrpc, + id: Some(id), + method, + params, + meta: RpcMeta::default(), + } +} + +/// Normalizes any JSON value into a request-shaped compatibility payload. +fn jsonrpc_request_from_value(value: Value) -> JsonRpcRequest { + match value { + Value::Object(object) => jsonrpc_request_from_object(object), + _ => invalid_request(Some(JsonRpcId::Null), Some("2.0".to_owned())), + } +} + +/// Parses a single JSON-RPC request from raw JSON text. +/// +/// # Errors +/// +/// Returns an error when `json` is not valid JSON text. +pub fn jsonrpc_request_from_json(json: &str) -> Result { + let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?; + Ok(jsonrpc_request_from_value(value)) +} + +/// Parses either a single or batch JSON-RPC payload from raw JSON text. +/// +/// # Errors +/// +/// Returns an error when `json` is not valid JSON text. +pub(super) fn jsonrpc_payload_from_json(json: &str) -> Result { + let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?; + match value { + Value::Array(items) => Ok(JsonRpcPayload::Batch( + items + .into_iter() + .filter_map(|item| match item { + Value::Object(object) => Some(Ok(jsonrpc_request_from_object(object))), + _ => None, + }) + .collect(), + )), + other => Ok(JsonRpcPayload::Single(jsonrpc_request_from_value(other))), + } +} + +/// Renders a JSON-RPC response to a compact JSON string. +/// +/// # Errors +/// +/// Returns an error when the response cannot be serialized to JSON text. +pub(super) fn jsonrpc_response_to_json(response: &JsonRpcResponse) -> Result { + serde_json::to_string(&jsonrpc_response_to_value(response)).map_err(|error| error.to_string()) +} + +/// Renders a JSON-RPC response to a `serde_json` value without a text round-trip. +fn jsonrpc_response_to_value(response: &JsonRpcResponse) -> Value { + let mut object = Map::new(); + object.insert( + "jsonrpc".to_owned(), + Value::String(response.jsonrpc.clone().unwrap_or_else(|| "2.0".to_owned())), + ); + object.insert( + "id".to_owned(), + response.id.as_ref().map_or(Value::Null, jsonrpc_id_to_json), + ); + if let Some(result) = &response.result { + object.insert("result".to_owned(), rpc_value_to_json(result)); + } + if let Some(error) = &response.error { + object.insert( + "error".to_owned(), + Value::Object(Map::from_iter([ + ( + "code".to_owned(), + Value::Number(Number::from(match error.code { + crate::model::RpcErrorCode::ParseError => -32700_i32, + crate::model::RpcErrorCode::InvalidRequest => -32600_i32, + crate::model::RpcErrorCode::MethodNotFound => -32601_i32, + crate::model::RpcErrorCode::InvalidParams => -32602_i32, + crate::model::RpcErrorCode::InternalError => -32603_i32, + crate::model::RpcErrorCode::ApplicationError => -32000_i32, + })), + ), + ("message".to_owned(), Value::String(error.message.clone())), + ])), + ); + } + Value::Object(object) +} + +/// Renders a batch of JSON-RPC responses to a compact JSON array string. +/// +/// # Errors +/// +/// Returns an error when any response cannot be serialized to JSON text. +pub(super) fn jsonrpc_batch_response_to_json( + responses: &[JsonRpcResponse], +) -> Result { + let rendered = responses + .iter() + .map(jsonrpc_response_to_value) + .collect::>(); + serde_json::to_string(&Value::Array(rendered)).map_err(|error| error.to_string()) +} + +#[cfg(test)] +/// Unit tests for `JSON-RPC` parsing and rendering helpers. +mod tests { + use super::*; + use crate::model::{RpcErrorCode, RpcErrorKind}; + + #[test] + /// Verifies that array parameters parse into the normalized request model. + fn parses_jsonrpc_request_with_array_params() { + let request = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","id":"q1","method":"aria2.tellStatus","params":["token:abc","2089b05ecca3d829"]}"#, + ) + .expect("request should parse"); + + assert_eq!(request.jsonrpc.as_deref(), Some("2.0")); + assert_eq!(request.id, Some(JsonRpcId::String("q1".to_owned()))); + assert_eq!(request.method, "aria2.tellStatus"); + assert_eq!(request.params.len(), 2); + } + + #[test] + /// Verifies that boolean and structured ids round-trip into the compatibility id surface. + fn parses_jsonrpc_request_with_boolean_and_structured_id() { + let boolean_id = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","id":true,"method":"aria2.getVersion","params":[]}"#, + ) + .expect("boolean id request should parse"); + assert_eq!(boolean_id.id, Some(JsonRpcId::Bool(true))); + + let object_id = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","id":{"client":"ui","seq":3},"method":"aria2.getVersion","params":[]}"#, + ) + .expect("object id request should parse"); + assert_eq!( + object_id.id, + Some(JsonRpcId::Object(BTreeMap::from([ + ("client".to_owned(), RpcValue::String("ui".to_owned())), + ("seq".to_owned(), RpcValue::Number(3)), + ]))) + ); + } + + #[test] + /// Verifies that a missing request id becomes the synthetic invalid-request wrapper. + fn missing_jsonrpc_id_becomes_synthetic_invalid_request() { + let request = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#, + ) + .expect("missing id request should still parse"); + + assert_eq!(request.id, Some(JsonRpcId::Null)); + assert_eq!(request.method, SYNTHETIC_INVALID_REQUEST_METHOD); + assert_eq!( + request.params, + vec![RpcValue::String("Invalid Request.".to_owned())] + ); + } + + #[test] + /// Verifies that named parameters become the synthetic invalid-params wrapper. + fn named_params_become_synthetic_invalid_params_request() { + let request = jsonrpc_request_from_json( + r#"{"jsonrpc":"2.0","id":"q2","method":"aria2.getVersion","params":{"gid":"abc"}}"#, + ) + .expect("named params request should still parse"); + + assert_eq!(request.id, Some(JsonRpcId::String("q2".to_owned()))); + assert_eq!(request.method, SYNTHETIC_INVALID_PARAMS_METHOD); + assert_eq!( + request.params, + vec![RpcValue::String("Invalid params.".to_owned())] + ); + } + + #[test] + /// Verifies that error responses render into the expected compact `JSON-RPC` shape. + fn renders_jsonrpc_error_response() { + let json = jsonrpc_response_to_json(&JsonRpcResponse { + jsonrpc: Some("2.0".to_owned()), + id: Some(JsonRpcId::Number(7)), + result: None, + error: Some(RpcError { + code: RpcErrorCode::InvalidParams, + kind: RpcErrorKind::InvalidParams, + message: "bad params".to_owned(), + }), + meta: RpcMeta::default(), + }) + .expect("response should render"); + + assert!(json.contains("\"jsonrpc\":\"2.0\"")); + assert!(json.contains("\"id\":7")); + assert!(json.contains("\"code\":-32602")); + assert!(json.contains("\"message\":\"bad params\"")); + } + + #[test] + /// Verifies that batch payloads preserve per-item request parsing. + fn parses_jsonrpc_batch_payload() { + let payload = jsonrpc_payload_from_json( + r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"aria2.tellActive","params":[]}]"#, + ) + .expect("batch payload should parse"); + + match payload { + JsonRpcPayload::Batch(items) => { + let [first, second] = items.as_slice() else { + panic!("expected exactly two batch items, got {}", items.len()); + }; + assert!(first.as_ref().is_ok_and(|request| request.id.is_some())); + assert!( + second + .as_ref() + .is_ok_and(|request| request.id == Some(JsonRpcId::Null)) + ); + } + other @ JsonRpcPayload::Single(_) => { + panic!("expected batch payload, got {other:?}") + } + } + } + + #[test] + /// Verifies that an empty batch parses as an empty batch payload. + fn parses_empty_jsonrpc_batch_payload_as_empty_batch() { + let payload = jsonrpc_payload_from_json("[]").expect("empty batch should parse"); + assert_eq!(payload, JsonRpcPayload::Batch(Vec::new())); + } + + #[test] + /// Verifies that non-object batch members are ignored like upstream aria2. + fn batch_payload_ignores_non_object_members_like_upstream_aria2() { + let payload = jsonrpc_payload_from_json( + r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7,true]"#, + ) + .expect("batch payload should parse"); + + match payload { + JsonRpcPayload::Batch(items) => { + let [first] = items.as_slice() else { + panic!("expected exactly one batch item, got {}", items.len()); + }; + assert!( + first + .as_ref() + .is_ok_and(|request| request.id == Some(JsonRpcId::Number(1))) + ); + } + other @ JsonRpcPayload::Single(_) => { + panic!("expected batch payload, got {other:?}") + } + } + } + + #[test] + /// Verifies that batch responses render as a compact JSON array. + fn renders_jsonrpc_batch_response() { + let json = jsonrpc_batch_response_to_json(&[ + JsonRpcResponse::success( + Some(JsonRpcId::Number(1)), + RpcValue::String("ok".to_owned()), + ), + JsonRpcResponse::error( + Some(JsonRpcId::Number(2)), + RpcError { + code: RpcErrorCode::InvalidRequest, + kind: RpcErrorKind::InvalidParams, + message: "bad request".to_owned(), + }, + ), + ]) + .expect("batch response should render"); + + assert!(json.starts_with('[')); + assert!(json.contains("\"id\":1")); + assert!(json.contains("\"id\":2")); + assert!(json.contains("\"bad request\"")); + } +} diff --git a/crates/aria2-rust-pro-rpc/src/lib.rs b/crates/aria2-rust-pro-rpc/src/lib.rs new file mode 100644 index 0000000..607e8d0 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/lib.rs @@ -0,0 +1,70 @@ +//! RPC compatibility surface for the `aria2-rust-pro` workspace. +//! +//! This crate owns request parsing, transport-neutral routing, JSON-RPC and +//! XML-RPC compatibility types, and the in-process dispatcher used by the +//! server-facing crates. +#![expect( + clippy::multiple_crate_versions, + reason = "workspace dependency resolution is shared across crates and not owned by rpc alone" +)] +#![forbid(unsafe_code)] + +#[cfg(test)] +use aria2_rust_pro_storage as _; + +/// In-process RPC dispatcher backed by the download engine. +pub(crate) mod dispatcher; +/// RPC method registry and request handlers. +pub(crate) mod handlers; +/// JSON-RPC request and response types plus serde helpers. +pub(crate) mod jsonrpc; +/// Canonical RPC method metadata. +pub(crate) mod methods; +/// Shared RPC value, error, and metadata model types. +pub(crate) mod model; +/// Transport-neutral request routing helpers. +pub(crate) mod router; +/// Minimal HTTP and WebSocket server glue for the RPC surface. +pub(crate) mod server; +/// Session and authentication token state. +pub(crate) mod session; +/// WebSocket notification fan-out helpers. +pub(crate) mod websocket; +/// XML-RPC request and response types plus parser/renderer helpers. +pub(crate) mod xmlrpc; + +/// Re-exports of the in-process dispatcher and lightweight internal summaries. +pub use dispatcher::{InProcessRpcDispatcher, RpcStatusSummary}; +/// Re-exports of the primary JSON-RPC message types and testable wire codecs. +pub use jsonrpc::{ + JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, jsonrpc_request_from_json, +}; +/// Re-exports of the RPC method catalog and lookup helpers. +pub use methods::{ + REQUIRED_RPC_METHODS, RPC_METHOD_LEDGER, RpcMethod, is_required_rpc_method, rpc_method_names, +}; +/// Re-exports of the shared RPC model types. +pub use model::{ + RpcAuthContext, RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcOptionMap, RpcResultEnvelope, + RpcValue, +}; +/// Re-exports of the transport-neutral router types. +pub use router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter}; +/// Re-exports of the server configuration and entrypoints. +pub use server::{ + RpcListenerStub, RpcServerConfig, RpcServerTransport, RpcServerTransportConfig, + serve_rpc_listener, +}; +/// Re-exports of RPC session state types. +pub use session::{RpcAuthToken, RpcSession, RpcSessionInfo, RpcSessionStore}; +/// Re-exports of WebSocket notification types and registries. +pub use websocket::{ + RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame, WebSocketNotificationRegistry, + WebSocketSessionRegistry, WebSocketSessionState, WebSocketSubscription, +}; +/// Re-exports of XML-RPC model types and wire codec helpers. +pub use xmlrpc::{ + XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue, + rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml, + xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc, +}; diff --git a/crates/aria2-rust-pro-rpc/src/methods.rs b/crates/aria2-rust-pro-rpc/src/methods.rs new file mode 100644 index 0000000..a2a958d --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/methods.rs @@ -0,0 +1,337 @@ +//! Canonical RPC method catalog and lookup helpers. +#![expect( + clippy::redundant_pub_crate, + reason = "method-catalog helpers stay crate-internal while retaining explicit visibilities" +)] + +#[cfg(test)] +use crate::model::RpcValue; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// RPC methods supported or reserved by the aria2-compatible surface. +pub enum RpcMethod { + /// Adds one or more download URIs. + Aria2AddUri, + /// Adds a torrent payload. + Aria2AddTorrent, + /// Adds a metalink payload. + Aria2AddMetalink, + /// Removes a download. + Aria2Remove, + /// Force-removes a download. + Aria2ForceRemove, + /// Pauses a download. + Aria2Pause, + /// Pauses all active downloads. + Aria2PauseAll, + /// Force-pauses a download. + Aria2ForcePause, + /// Force-pauses all active downloads. + Aria2ForcePauseAll, + /// Resumes a paused download. + Aria2Unpause, + /// Resumes all paused downloads. + Aria2UnpauseAll, + /// Returns status for a single download. + Aria2TellStatus, + /// Returns active downloads. + Aria2TellActive, + /// Returns waiting downloads. + Aria2TellWaiting, + /// Returns stopped downloads. + Aria2TellStopped, + /// Returns URIs for a download. + Aria2GetUris, + /// Returns files for a download. + Aria2GetFiles, + /// Returns peers for a `BitTorrent` download. + Aria2GetPeers, + /// Returns servers for a download. + Aria2GetServers, + /// Changes queue position for a download. + Aria2ChangePosition, + /// Changes URIs attached to a download. + Aria2ChangeUri, + /// Purges completed download results. + Aria2PurgeDownloadResult, + /// Removes one completed download result. + Aria2RemoveDownloadResult, + /// Returns aggregate global statistics. + Aria2TellGlobalStat, + /// Returns per-download options. + Aria2GetOption, + /// Changes per-download options. + Aria2ChangeOption, + /// Returns global options. + Aria2GetGlobalOption, + /// Changes global options. + Aria2ChangeGlobalOption, + /// Returns version and enabled feature metadata. + Aria2GetVersion, + /// Returns session metadata. + Aria2GetSessionInfo, + /// Persists the current session. + Aria2SaveSession, + /// Requests graceful shutdown. + Aria2Shutdown, + /// Requests forced shutdown. + Aria2ForceShutdown, + /// Executes a batch of nested method calls. + SystemMulticall, + /// Returns the method catalog. + SystemListMethods, + /// Returns the notification catalog. + SystemListNotifications, +} + +impl RpcMethod { + /// Returns the canonical method name exposed on the wire. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Aria2AddUri => "aria2.addUri", + Self::Aria2AddTorrent => "aria2.addTorrent", + Self::Aria2AddMetalink => "aria2.addMetalink", + Self::Aria2Remove => "aria2.remove", + Self::Aria2ForceRemove => "aria2.forceRemove", + Self::Aria2Pause => "aria2.pause", + Self::Aria2PauseAll => "aria2.pauseAll", + Self::Aria2ForcePause => "aria2.forcePause", + Self::Aria2ForcePauseAll => "aria2.forcePauseAll", + Self::Aria2Unpause => "aria2.unpause", + Self::Aria2UnpauseAll => "aria2.unpauseAll", + Self::Aria2TellStatus => "aria2.tellStatus", + Self::Aria2TellActive => "aria2.tellActive", + Self::Aria2TellWaiting => "aria2.tellWaiting", + Self::Aria2TellStopped => "aria2.tellStopped", + Self::Aria2GetUris => "aria2.getUris", + Self::Aria2GetFiles => "aria2.getFiles", + Self::Aria2GetPeers => "aria2.getPeers", + Self::Aria2GetServers => "aria2.getServers", + Self::Aria2ChangePosition => "aria2.changePosition", + Self::Aria2ChangeUri => "aria2.changeUri", + Self::Aria2PurgeDownloadResult => "aria2.purgeDownloadResult", + Self::Aria2RemoveDownloadResult => "aria2.removeDownloadResult", + Self::Aria2TellGlobalStat => "aria2.getGlobalStat", + Self::Aria2GetOption => "aria2.getOption", + Self::Aria2ChangeOption => "aria2.changeOption", + Self::Aria2GetGlobalOption => "aria2.getGlobalOption", + Self::Aria2ChangeGlobalOption => "aria2.changeGlobalOption", + Self::Aria2GetVersion => "aria2.getVersion", + Self::Aria2GetSessionInfo => "aria2.getSessionInfo", + Self::Aria2SaveSession => "aria2.saveSession", + Self::Aria2Shutdown => "aria2.shutdown", + Self::Aria2ForceShutdown => "aria2.forceShutdown", + Self::SystemMulticall => "system.multicall", + Self::SystemListMethods => "system.listMethods", + Self::SystemListNotifications => "system.listNotifications", + } + } + + /// Returns accepted legacy aliases for compatibility handling. + #[must_use] + pub const fn legacy_aliases(self) -> &'static [&'static str] { + match self { + Self::Aria2TellGlobalStat => &["aria2.tellGlobalStat"], + Self::SystemMulticall => &["aria2.multicall"], + _ => &[], + } + } +} + +/// Canonical method ledger in the order exposed to compatibility consumers. +pub const RPC_METHOD_LEDGER: &[RpcMethod] = &[ + RpcMethod::Aria2AddUri, + RpcMethod::Aria2AddTorrent, + RpcMethod::Aria2GetPeers, + RpcMethod::Aria2AddMetalink, + RpcMethod::Aria2Remove, + RpcMethod::Aria2Pause, + RpcMethod::Aria2ForcePause, + RpcMethod::Aria2PauseAll, + RpcMethod::Aria2ForcePauseAll, + RpcMethod::Aria2Unpause, + RpcMethod::Aria2UnpauseAll, + RpcMethod::Aria2ForceRemove, + RpcMethod::Aria2ChangePosition, + RpcMethod::Aria2TellStatus, + RpcMethod::Aria2GetUris, + RpcMethod::Aria2GetFiles, + RpcMethod::Aria2GetServers, + RpcMethod::Aria2TellActive, + RpcMethod::Aria2TellWaiting, + RpcMethod::Aria2TellStopped, + RpcMethod::Aria2GetOption, + RpcMethod::Aria2ChangeUri, + RpcMethod::Aria2ChangeOption, + RpcMethod::Aria2GetGlobalOption, + RpcMethod::Aria2ChangeGlobalOption, + RpcMethod::Aria2PurgeDownloadResult, + RpcMethod::Aria2RemoveDownloadResult, + RpcMethod::Aria2GetVersion, + RpcMethod::Aria2GetSessionInfo, + RpcMethod::Aria2Shutdown, + RpcMethod::Aria2ForceShutdown, + RpcMethod::Aria2TellGlobalStat, + RpcMethod::Aria2SaveSession, + RpcMethod::SystemMulticall, + RpcMethod::SystemListMethods, + RpcMethod::SystemListNotifications, +]; + +/// Required upstream-compatible method names that should resolve distinctly. +pub const REQUIRED_RPC_METHODS: &[&str] = &[ + "aria2.addUri", + "aria2.addTorrent", + "aria2.getPeers", + "aria2.addMetalink", + "aria2.remove", + "aria2.pause", + "aria2.forcePause", + "aria2.pauseAll", + "aria2.forcePauseAll", + "aria2.unpause", + "aria2.unpauseAll", + "aria2.forceRemove", + "aria2.changePosition", + "aria2.tellStatus", + "aria2.getUris", + "aria2.getFiles", + "aria2.getServers", + "aria2.tellActive", + "aria2.tellWaiting", + "aria2.tellStopped", + "aria2.getOption", + "aria2.changeUri", + "aria2.changeOption", + "aria2.getGlobalOption", + "aria2.changeGlobalOption", + "aria2.purgeDownloadResult", + "aria2.removeDownloadResult", + "aria2.getVersion", + "aria2.getSessionInfo", + "aria2.shutdown", + "aria2.forceShutdown", + "aria2.getGlobalStat", + "aria2.saveSession", + "system.multicall", + "system.listMethods", + "system.listNotifications", +]; + +/// Canonical WebSocket notification names in upstream-compatible order. +const RPC_NOTIFICATION_NAMES: &[&str] = &[ + "aria2.onDownloadStart", + "aria2.onDownloadPause", + "aria2.onDownloadStop", + "aria2.onDownloadComplete", + "aria2.onDownloadError", + "aria2.onBtDownloadComplete", +]; + +/// Returns whether a method name is part of the required compatibility set. +#[must_use] +pub fn is_required_rpc_method(method: &str) -> bool { + REQUIRED_RPC_METHODS.contains(&method) +} + +/// Resolves a method name or legacy alias into the canonical method variant. +#[must_use] +pub(super) fn rpc_method(name: &str) -> Option { + RPC_METHOD_LEDGER + .iter() + .copied() + .find(|method| method.as_str() == name || method.legacy_aliases().contains(&name)) +} + +/// Returns the canonical RPC method names in ledger order. +#[must_use] +pub fn rpc_method_names() -> Vec<&'static str> { + RPC_METHOD_LEDGER + .iter() + .copied() + .map(RpcMethod::as_str) + .collect() +} + +/// Returns the canonical WebSocket notification method names. +#[must_use] +pub(super) fn rpc_notification_names() -> Vec<&'static str> { + RPC_NOTIFICATION_NAMES.to_vec() +} + +/// Builds an RPC value containing the canonical method name list. +#[must_use] +#[cfg(test)] +fn method_name_value_list() -> RpcValue { + RpcValue::Array( + rpc_method_names() + .into_iter() + .map(|m| RpcValue::String(m.to_owned())) + .collect(), + ) +} + +#[cfg(test)] +/// Unit tests for the public RPC method and notification catalog. +mod tests { + use super::{ + RpcMethod, method_name_value_list, rpc_method, rpc_method_names, rpc_notification_names, + }; + use crate::model::RpcValue; + + #[test] + /// Verifies that legacy aliases resolve internally without leaking into public catalogs. + fn legacy_aliases_resolve_but_do_not_leak_into_public_method_names() { + assert_eq!( + rpc_method("aria2.tellGlobalStat"), + Some(RpcMethod::Aria2TellGlobalStat) + ); + assert_eq!( + rpc_method("aria2.multicall"), + Some(RpcMethod::SystemMulticall) + ); + + let method_names = rpc_method_names(); + assert!(method_names.contains(&"aria2.getGlobalStat")); + assert!(method_names.contains(&"system.multicall")); + assert!(!method_names.contains(&"aria2.tellGlobalStat")); + assert!(!method_names.contains(&"aria2.multicall")); + } + + #[test] + /// Verifies that public notification names keep the upstream aria2 order. + fn public_notification_names_keep_upstream_order() { + assert_eq!( + rpc_notification_names(), + vec![ + "aria2.onDownloadStart", + "aria2.onDownloadPause", + "aria2.onDownloadStop", + "aria2.onDownloadComplete", + "aria2.onDownloadError", + "aria2.onBtDownloadComplete", + ] + ); + } + + #[test] + /// Verifies that `system.listMethods` emits only public method names. + fn method_name_value_list_uses_public_names_only() { + match method_name_value_list() { + RpcValue::Array(values) => { + let names = values + .into_iter() + .map(|value| match value { + RpcValue::String(name) => name, + other => panic!("unexpected method value: {other:?}"), + }) + .collect::>(); + assert!(names.iter().any(|name| name == "aria2.getGlobalStat")); + assert!(!names.iter().any(|name| name == "aria2.tellGlobalStat")); + assert!(!names.iter().any(|name| name == "aria2.multicall")); + } + other => panic!("unexpected method list payload: {other:?}"), + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/model.rs b/crates/aria2-rust-pro-rpc/src/model.rs new file mode 100644 index 0000000..72e4e82 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/model.rs @@ -0,0 +1,217 @@ +//! Shared value, error, and metadata types used across RPC transports. +#![expect( + clippy::redundant_pub_crate, + reason = "shared RPC model items stay crate-internal while retaining explicit visibilities" +)] +use std::collections::BTreeMap; + +/// Canonical RPC option map shape for aria2-compatible option payloads. +pub type RpcOptionMap = BTreeMap; +/// BitTorrent-specific status fields that aria2-compatible clients may request. +pub(super) const BT_STATUS_FIELDS: &[&str] = &[ + "infoHash", + "numSeeders", + "seeder", + "connections", + "activeSegments", + "pieceLength", + "numPieces", + "completedPieces", + "bitfield", + "announceList", + "followedBy", + "following", + "belongsTo", + "verifiedLength", + "verifyIntegrityPending", + "isBt", + "metadataOnly", + "magnetUri", + "creationDate", + "comment", + "mode", +]; + +#[derive(Debug, Clone, PartialEq)] +/// Transport-neutral RPC value representation. +pub enum RpcValue { + /// Null or absent value. + Null, + /// Boolean scalar value. + Bool(bool), + /// Signed integer value. + Number(i64), + /// UTF-8 string value. + String(String), + /// Ordered list of nested values. + Array(Vec), + /// Map of named nested values. + Object(BTreeMap), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Broad category for RPC failures. +pub enum RpcErrorKind { + /// The method name is not recognized. + UnknownMethod, + /// Parameters are malformed or unsupported. + InvalidParams, + /// Authentication or authorization failed. + Unauthorized, + /// The method exists but is not implemented by this backend. + Unsupported, + /// The backend encountered an unexpected internal failure. + Internal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// JSON-RPC compatible error code surface. +pub enum RpcErrorCode { + /// Invalid JSON payload syntax. + ParseError = -32700, + /// Payload shape is not a valid JSON-RPC request. + InvalidRequest = -32600, + /// Method name is unknown. + MethodNotFound = -32601, + /// Parameters are invalid for the method. + InvalidParams = -32602, + /// Generic server-side failure. + InternalError = -32603, + /// Application-defined aria2-compatible failure. + ApplicationError = -32000, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Normalized error payload shared across transports. +pub struct RpcError { + /// JSON-RPC compatible numeric code. + pub code: RpcErrorCode, + /// Broad error classification. + pub kind: RpcErrorKind, + /// Human-readable failure message. + pub message: String, +} + +impl RpcError { + #[must_use] + /// Builds a method-not-found error in aria2-compatible wording. + pub fn unknown_method(method: &str) -> Self { + Self { + code: RpcErrorCode::MethodNotFound, + kind: RpcErrorKind::UnknownMethod, + message: format!("Method not found: {method}"), + } + } + #[must_use] + /// Builds a parse error with the provided message. + pub fn parse_error(message: &str) -> Self { + Self { + code: RpcErrorCode::ParseError, + kind: RpcErrorKind::InvalidParams, + message: message.to_owned(), + } + } + #[must_use] + /// Builds an invalid-request error with the provided message. + pub fn invalid_request(message: &str) -> Self { + Self { + code: RpcErrorCode::InvalidRequest, + kind: RpcErrorKind::InvalidParams, + message: message.to_owned(), + } + } + #[must_use] + /// Builds an unsupported-method error with the provided message. + pub fn unsupported(message: &str) -> Self { + Self { + code: RpcErrorCode::ApplicationError, + kind: RpcErrorKind::Unsupported, + message: message.to_owned(), + } + } + #[must_use] + /// Builds an invalid-parameters error with the provided message. + pub fn invalid_params(message: &str) -> Self { + Self { + code: RpcErrorCode::InvalidParams, + kind: RpcErrorKind::InvalidParams, + message: message.to_owned(), + } + } + #[must_use] + /// Builds an authorization failure with the provided message. + pub fn unauthorized(message: &str) -> Self { + Self { + code: RpcErrorCode::ApplicationError, + kind: RpcErrorKind::Unauthorized, + message: message.to_owned(), + } + } + + #[must_use] + /// Returns the XML-RPC fault code used for this normalized error. + pub const fn xml_fault_code(&self) -> i32 { + 1 + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Transport-neutral result envelope returned by handlers. +pub struct RpcResultEnvelope { + /// Successful result payload, if any. + pub result: Option, + /// Error payload, if the request failed. + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// Optional metadata attached to inbound or outbound RPC traffic. +pub struct RpcMeta { + /// Correlation identifier propagated across RPC hops. + pub trace_id: Option, + /// Logical client identifier, if known. + pub client: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// Authentication context derived from the active transport session. +pub struct RpcAuthContext { + /// Secret token presented by the client. + pub token: Option, + /// Transport session identifier, if one has been established. + pub session_id: Option, + /// Whether the current request is authenticated. + pub authenticated: bool, +} + +#[cfg(test)] +/// Unit tests for the shared RPC model surface. +mod tests { + use super::*; + + #[test] + /// Verifies that unknown methods use the upstream aria2 wording. + fn unknown_method_uses_upstream_style_message() { + let error = RpcError::unknown_method("aria2.notFound"); + + assert_eq!(error.code, RpcErrorCode::MethodNotFound); + assert_eq!(error.kind, RpcErrorKind::UnknownMethod); + assert_eq!(error.message, "Method not found: aria2.notFound"); + assert_eq!(error.xml_fault_code(), 1); + } + + #[test] + /// Verifies that parse and invalid-request errors keep their transport codes. + fn parse_and_invalid_request_keep_transport_codes() { + let parse = RpcError::parse_error("unexpected trailing token"); + let invalid = RpcError::invalid_request("jsonrpc batch request must not be empty"); + + assert_eq!(parse.code, RpcErrorCode::ParseError); + assert_eq!(parse.kind, RpcErrorKind::InvalidParams); + assert_eq!(parse.message, "unexpected trailing token"); + + assert_eq!(invalid.code, RpcErrorCode::InvalidRequest); + assert_eq!(invalid.kind, RpcErrorKind::InvalidParams); + assert_eq!(invalid.message, "jsonrpc batch request must not be empty"); + } +} diff --git a/crates/aria2-rust-pro-rpc/src/router.rs b/crates/aria2-rust-pro-rpc/src/router.rs new file mode 100644 index 0000000..db8b952 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/router.rs @@ -0,0 +1,280 @@ +//! Transport-neutral routing between request shapes and handler outputs. + +use crate::{ + handlers::{RpcHandlerContext, RpcHandlerRegistry}, + jsonrpc::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}, + model::{RpcError, RpcMeta, RpcResultEnvelope, RpcValue}, + xmlrpc::{ + XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, rpc_value_to_xmlrpc, + xmlrpc_value_to_rpc, + }, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Transport-neutral inbound request variants accepted by the router. +pub enum RpcDispatchRequest { + /// `JSON-RPC` request expecting a response. + Json(JsonRpcRequest), + /// `JSON-RPC` notification with no response body. + JsonNotification(JsonRpcNotification), + /// `XML-RPC` method call expecting a response. + Xml(XmlRpcMethodCall), +} + +#[derive(Debug, Clone, PartialEq)] +/// Transport-neutral outbound response variants produced by the router. +pub enum RpcDispatchResult { + /// `JSON-RPC` response payload. + Json(JsonRpcResponse), + /// `XML-RPC` response payload. + Xml(XmlRpcMethodResponse), + /// No response should be emitted. + Empty, +} + +#[derive(Debug, Clone, Copy, Default)] +/// Shared router that normalizes transport-specific requests into handler calls. +pub struct RpcRouter { + /// Shared handler registry reused across `JSON-RPC` and `XML-RPC` dispatch paths. + handlers: RpcHandlerRegistry, +} + +impl RpcRouter { + #[must_use] + /// Creates a router with the default handler registry. + pub const fn new() -> Self { + Self { + handlers: RpcHandlerRegistry, + } + } + + #[must_use] + /// Returns the shared handler registry. + pub const fn handlers(&self) -> &RpcHandlerRegistry { + &self.handlers + } + + #[must_use] + /// Returns a mutable handler registry reference. + pub const fn handlers_mut(&mut self) -> &mut RpcHandlerRegistry { + &mut self.handlers + } + + /// Dispatches a transport-neutral request through the handler registry. + pub fn dispatch( + &mut self, + request: RpcDispatchRequest, + ctx: RpcHandlerContext, + ) -> RpcDispatchResult { + match request { + RpcDispatchRequest::Json(request) => { + let request_id = request.id.clone(); + let envelope = self.handlers.handle_json(request, ctx); + RpcDispatchResult::Json(envelope.into_response(request_id)) + } + RpcDispatchRequest::JsonNotification(notification) => { + self.handlers.handle_notification(notification, ctx); + RpcDispatchResult::Empty + } + RpcDispatchRequest::Xml(request) => RpcDispatchResult::Xml( + self.handlers + .handle_json(json_request_from_xmlrpc(request), ctx) + .into_xml_response(), + ), + } + } +} + +impl RpcResultEnvelope { + #[must_use] + /// Converts an envelope into a `JSON-RPC` response with the provided id. + pub fn into_response(self, id: Option) -> JsonRpcResponse { + let (result, error) = self.normalize_for_response(); + JsonRpcResponse { + jsonrpc: Some("2.0".to_owned()), + id, + result, + error, + meta: RpcMeta::default(), + } + } + + #[must_use] + /// Converts an envelope into an `XML-RPC` method response. + pub fn into_xml_response(self) -> XmlRpcMethodResponse { + let (result, error) = self.normalize_for_response(); + match error { + Some(error) => { + let message = error.message.clone(); + XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: error.xml_fault_code(), + message, + error: Some(error), + }), + meta: RpcMeta::default(), + } + } + None => XmlRpcMethodResponse { + value: Some(rpc_value_to_xmlrpc( + result.map_or(RpcValue::Null, |result| result), + )), + fault: None, + meta: RpcMeta::default(), + }, + } + } + + /// Normalizes the envelope so transport encoders always see either a result or an error. + fn normalize_for_response(self) -> (Option, Option) { + match (self.result, self.error) { + (_, Some(error)) => (None, Some(error)), + (Some(result), None) => (Some(result), None), + (None, None) => (Some(RpcValue::Null), None), + } + } +} + +/// Converts a normalized error into an envelope with no successful result payload. +impl From for RpcResultEnvelope { + fn from(error: RpcError) -> Self { + Self { + result: None, + error: Some(error), + } + } +} + +/// Re-expresses an `XML-RPC` method call as the equivalent `JSON-RPC` request shape. +fn json_request_from_xmlrpc(request: XmlRpcMethodCall) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: request.method_name, + params: request + .params + .into_iter() + .map(|XmlRpcParam { value }| xmlrpc_value_to_rpc(value)) + .collect(), + meta: request.meta, + } +} + +#[cfg(test)] +/// Unit tests for the transport-neutral RPC router. +mod tests { + use super::*; + use crate::{ + jsonrpc::JsonRpcId, + model::{RpcAuthContext, RpcErrorCode, RpcErrorKind}, + xmlrpc::XmlRpcValue, + }; + + /// Builds a default handler context for router tests. + fn ctx() -> RpcHandlerContext { + RpcHandlerContext { + auth: RpcAuthContext::default(), + meta: RpcMeta::default(), + } + } + + #[test] + /// Verifies that an empty envelope becomes a `null` `JSON-RPC` result. + fn json_response_normalizes_empty_envelope_to_null_result() { + let response = RpcResultEnvelope { + result: None, + error: None, + } + .into_response(Some(JsonRpcId::Number(7))); + + assert_eq!(response.id, Some(JsonRpcId::Number(7))); + assert_eq!(response.result, Some(RpcValue::Null)); + assert!(response.error.is_none()); + } + + #[test] + /// Verifies that error payloads take precedence over successful results. + fn json_response_prefers_error_over_result() { + let response = RpcResultEnvelope { + result: Some(RpcValue::String("ignored".to_owned())), + error: Some(RpcError::invalid_params("bad params")), + } + .into_response(Some(JsonRpcId::String("req-1".to_owned()))); + + assert_eq!(response.id, Some(JsonRpcId::String("req-1".to_owned()))); + assert!(response.result.is_none()); + let error = response.error.expect("error should win"); + assert_eq!(error.code, RpcErrorCode::InvalidParams); + assert_eq!(error.kind, RpcErrorKind::InvalidParams); + assert_eq!(error.message, "bad params"); + } + + #[test] + /// Verifies that an empty envelope becomes an `XML-RPC` nil value. + fn xml_response_normalizes_empty_envelope_to_nil_value() { + let response = RpcResultEnvelope { + result: None, + error: None, + } + .into_xml_response(); + + assert!(response.fault.is_none()); + assert_eq!(response.value, Some(XmlRpcValue::Nil)); + } + + #[test] + /// Verifies that unknown `XML-RPC` methods reuse the shared fault shape. + fn xml_dispatch_uses_shared_fault_shape_for_unknown_method() { + let mut router = RpcRouter::new(); + let response = match router.dispatch( + RpcDispatchRequest::Xml(XmlRpcMethodCall { + method_name: "aria2.notFound".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }), + ctx(), + ) { + RpcDispatchResult::Xml(response) => response, + other => panic!("unexpected dispatch result: {other:?}"), + }; + + assert!(response.value.is_none()); + let fault = response.fault.expect("fault expected"); + assert_eq!(fault.code, 1); + assert_eq!(fault.message, "Method not found: aria2.notFound"); + assert_eq!( + fault.error, + Some(RpcError::unknown_method("aria2.notFound")) + ); + } + + #[test] + /// Verifies that `XML-RPC` dispatch reuses the shared JSON-backed handler results. + fn xml_dispatch_reuses_json_handler_results_for_list_methods() { + let mut router = RpcRouter::new(); + let response = match router.dispatch( + RpcDispatchRequest::Xml(XmlRpcMethodCall { + method_name: "system.listMethods".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }), + ctx(), + ) { + RpcDispatchResult::Xml(response) => response, + other => panic!("unexpected dispatch result: {other:?}"), + }; + + assert!(response.fault.is_none()); + match response.value { + Some(XmlRpcValue::Array(items)) => { + assert!(!items.is_empty(), "listMethods should not be empty"); + assert!(items.iter().any(|item| matches!( + item, + XmlRpcValue::String(name) if name == "system.listMethods" + ))); + } + other => panic!("unexpected XML value: {other:?}"), + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/server.rs b/crates/aria2-rust-pro-rpc/src/server.rs new file mode 100644 index 0000000..850a0a3 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server.rs @@ -0,0 +1,38 @@ +//! Minimal HTTP and WebSocket server glue for the RPC surface. +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::case_sensitive_file_extension_comparisons, + clippy::cast_possible_truncation, + clippy::indexing_slicing, + clippy::missing_const_for_fn, + clippy::needless_pass_by_value, + clippy::question_mark, + reason = "the server glue keeps low-level transport code explicit to preserve wire compatibility" +)] + +/// Shared runtime configuration and lightweight listener stub types. +mod config; +/// HTTP request parsing and RPC response shaping helpers. +mod http_surface; +/// Listener runtime and accepted-connection worker orchestration. +mod transport_runtime; +/// WebSocket frame dispatch helpers layered over the shared RPC dispatcher. +mod websocket_dispatch; +/// WebSocket upgrade validation and response shaping helpers. +mod websocket_handshake; +/// Upgraded WebSocket session runtime and flush-loop helpers. +mod websocket_session; +/// WebSocket upgrade/runtime facade shared by the listener runtime. +mod websocket_surface; +/// WebSocket frame codec helpers for upgraded streams. +mod websocket_wire; + +use self::config::WEBSOCKET_IDLE_POLL_INTERVAL; +pub use self::{ + config::{RpcListenerStub, RpcServerConfig}, + transport_runtime::{RpcServerTransport, RpcServerTransportConfig, serve_rpc_listener}, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/aria2-rust-pro-rpc/src/server/config.rs b/crates/aria2-rust-pro-rpc/src/server/config.rs new file mode 100644 index 0000000..ae52e84 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/config.rs @@ -0,0 +1,56 @@ +use std::{net::SocketAddr, time::Duration}; + +/// Idle poll interval used while waiting for queued WebSocket notifications. +pub(super) const WEBSOCKET_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100); + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Runtime configuration for the RPC HTTP server. +pub struct RpcServerConfig { + /// Whether JSON-RPC over HTTP is enabled. + pub enable_json_rpc: bool, + /// Whether XML-RPC over HTTP is enabled. + pub enable_xml_rpc: bool, + /// Whether JSON-RPC over WebSocket is enabled. + pub enable_websocket_rpc: bool, + /// Socket address the listener binds to. + pub listen_addr: SocketAddr, + /// Shared secret token required by protected methods. + pub secret_token: Option, + /// Allowed CORS origin, if cross-origin requests are permitted. + pub allow_origin: Option, +} + +impl Default for RpcServerConfig { + fn default() -> Self { + Self { + enable_json_rpc: true, + enable_xml_rpc: true, + enable_websocket_rpc: false, + listen_addr: SocketAddr::from(([127, 0, 0, 1], 6800)), + secret_token: None, + allow_origin: None, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +/// Minimal listener stub used by tests and integration layers. +pub struct RpcListenerStub { + /// Bound socket address, if the stub has been bound. + pub bound_addr: Option, + /// Whether the stub is considered active. + pub active: bool, +} + +impl RpcListenerStub { + /// Marks the stub as bound to the provided address. + pub fn bind(&mut self, addr: SocketAddr) { + self.bound_addr = Some(addr); + self.active = true; + } + + /// Marks the stub as closed. + pub fn close(&mut self) { + self.active = false; + } +} diff --git a/crates/aria2-rust-pro-rpc/src/server/http_surface.rs b/crates/aria2-rust-pro-rpc/src/server/http_surface.rs new file mode 100644 index 0000000..058943c --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/http_surface.rs @@ -0,0 +1,544 @@ +use std::{ + collections::BTreeMap, + io::{self, Read}, + net::TcpStream, + str, + sync::{Arc, Mutex}, + time::Duration, +}; + +use crate::{ + InProcessRpcDispatcher, JsonRpcRequest, + jsonrpc::{ + JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json, + jsonrpc_response_to_json, + }, + model::{RpcError, RpcErrorCode, RpcMeta, RpcValue}, + xmlrpc::{ + XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue, + xmlrpc_method_call_from_xml, xmlrpc_method_response_to_xml, + }, +}; + +use super::{RpcServerConfig, websocket_surface::websocket_upgrade_response}; + +#[derive(Debug, Clone)] +/// Buffered HTTP request model used by the synchronous transport helpers. +pub(super) struct HttpRequest { + /// Request method token from the HTTP start line. + pub(super) method: String, + /// Raw request target path from the HTTP start line. + pub(super) path: String, + /// Lower-cased request headers. + pub(super) headers: BTreeMap, + /// Fully buffered request body bytes. + pub(super) body: Vec, +} + +#[derive(Debug)] +/// Prepared HTTP RPC request after transport-level validation and body parsing. +enum PreparedHttpRpcRequest { + /// An XML-RPC request body ready for dispatcher execution. + Xml(XmlRpcMethodCall), + /// A JSON-RPC payload ready for dispatcher execution. + Json(JsonRpcPayload), +} + +/// Strips any query string or fragment from an inbound request target. +pub(super) fn request_path_without_query_or_fragment(path: &str) -> &str { + path.split(['?', '#']).next().unwrap_or(path) +} + +/// Normalizes supported RPC paths so equivalent HTTP targets share one route key. +pub(super) fn normalized_rpc_path(path: &str) -> &str { + let path = request_path_without_query_or_fragment(path); + if path.len() > 1 { + path.trim_end_matches('/') + } else { + path + } +} + +/// Looks up an HTTP header by name using case-insensitive matching. +pub(super) fn header_value<'a>( + headers: &'a BTreeMap, + name: &str, +) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +/// Returns whether an HTTP header exists, ignoring header-name case. +pub(super) fn has_header(headers: &BTreeMap, name: &str) -> bool { + header_value(headers, name).is_some() +} + +/// Reads and buffers a single HTTP request from a client stream. +pub(super) fn read_http_request( + stream: &mut TcpStream, + request_timeout: Duration, +) -> io::Result> { + stream.set_read_timeout(Some(request_timeout))?; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk)?; + if read == 0 { + if buffer.is_empty() { + return Ok(None); + } + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if buffer.len() > 1024 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "request too large", + )); + } + } + + let header_end = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request headers"))?; + let (header_bytes, body_prefix) = buffer.split_at(header_end); + let header_text = str::from_utf8(header_bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + let mut header_lines = header_text.lines(); + let request_line = header_lines + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? + .to_owned(); + let path = request_parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? + .to_owned(); + let headers = header_lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned())) + .collect::>(); + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + + let mut body = body_prefix.to_vec(); + while body.len() < content_length { + let read = stream.read(&mut chunk)?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + body.truncate(content_length); + + Ok(Some(HttpRequest { + method, + path, + headers, + body, + })) +} + +/// Builds a minimal HTTP response with the supplied content type and body. +pub(super) fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(body); + response +} + +/// Builds an empty `204 No Content` HTTP response. +pub(super) fn http_no_content_response() -> Vec { + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() +} + +/// Builds a minimal HTTP response while allowing additional headers to be injected. +pub(super) fn http_response_with_headers( + status: &str, + content_type: Option<&str>, + extra_headers: &[(&str, String)], + body: &[u8], +) -> Vec { + let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes(); + if let Some(content_type) = content_type { + response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes()); + } + response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes()); + for (name, value) in extra_headers { + response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + response.extend_from_slice(b"\r\n"); + response.extend_from_slice(body); + response +} + +/// Produces the CORS headers required for either a normal RPC response or a preflight reply. +pub(super) fn cors_response_headers( + config: &RpcServerConfig, + requested_headers: Option<&str>, + preflight: bool, +) -> Vec<(&'static str, String)> { + let Some(origin) = &config.allow_origin else { + return Vec::new(); + }; + + let mut headers = vec![("Access-Control-Allow-Origin", origin.clone())]; + if preflight { + headers.push(( + "Access-Control-Allow-Methods", + "POST, GET, OPTIONS".to_owned(), + )); + headers.push(( + "Access-Control-Allow-Headers", + requested_headers + .filter(|value| !value.trim().is_empty()) + .unwrap_or("content-type") + .to_owned(), + )); + } + headers +} + +/// Builds an RPC HTTP response and attaches configured CORS headers. +pub(super) fn rpc_http_response( + config: &RpcServerConfig, + status: &str, + content_type: &str, + body: &[u8], +) -> Vec { + let headers = cors_response_headers(config, None, false); + http_response_with_headers(status, Some(content_type), &headers, body) +} + +/// Builds an empty RPC HTTP response and attaches configured CORS headers. +pub(super) fn rpc_http_no_content_response( + config: &RpcServerConfig, + requested_headers: Option<&str>, +) -> Vec { + let headers = cors_response_headers(config, requested_headers, true); + http_response_with_headers("204 No Content", None, &headers, b"") +} + +/// Returns whether an HTTP request looks like a WebSocket upgrade handshake for RPC. +pub(super) fn is_websocket_upgrade_candidate(request: &HttpRequest) -> bool { + request.method.eq_ignore_ascii_case("GET") + && normalized_rpc_path(&request.path) == "/jsonrpc" + && (header_value(&request.headers, "upgrade") + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) + || has_header(&request.headers, "sec-websocket-key") + || has_header(&request.headers, "sec-websocket-version") + || header_value(&request.headers, "connection") + .is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade"))) +} + +/// Removes an optional leading `token:` secret from JSON-RPC positional parameters. +fn extract_rpc_token(params: &mut Vec) -> Option { + if let Some(RpcValue::String(token)) = params.first() + && let Some(value) = token.strip_prefix("token:") + { + let value = value.to_owned(); + params.remove(0); + return Some(value); + } + None +} + +/// Returns whether an RPC method remains callable without the shared secret token. +fn rpc_method_skips_secret(method: &str) -> bool { + matches!(method, "system.listMethods" | "system.listNotifications") +} + +/// Dispatches one JSON-RPC request after applying the shared-secret compatibility rules. +pub(super) fn dispatch_json_request( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + mut request: JsonRpcRequest, +) -> JsonRpcResponse { + let provided_token = extract_rpc_token(&mut request.params); + if let Some(secret) = &config.secret_token + && !rpc_method_skips_secret(&request.method) + && provided_token.as_deref() != Some(secret.as_str()) + { + return JsonRpcResponse::error( + request.id, + RpcError::unauthorized("RPC secret required or invalid token"), + ); + } + dispatcher.dispatch_json(request) +} + +/// Dispatches one XML-RPC request after applying the shared-secret compatibility rules. +pub(super) fn dispatch_xml_request( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + mut request: XmlRpcMethodCall, +) -> XmlRpcMethodResponse { + let provided_token = if let Some(XmlRpcParam { + value: XmlRpcValue::String(token), + }) = request.params.first() + && let Some(value) = token.strip_prefix("token:") + { + Some(value.to_owned()) + } else { + None + }; + if provided_token.is_some() { + request.params.remove(0); + } + if let Some(secret) = &config.secret_token + && !rpc_method_skips_secret(&request.method_name) + && provided_token.as_deref() != Some(secret.as_str()) + { + return XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: 1, + message: "RPC secret required or invalid token".to_owned(), + error: Some(RpcError::unauthorized( + "RPC secret required or invalid token", + )), + }), + meta: RpcMeta::default(), + }; + } + dispatcher.dispatch_xml(request) +} + +/// Trims a UTF-8 BOM, comments, and processing instructions before XML-RPC sniffing. +fn trim_xml_prelude(mut body: &str) -> &str { + loop { + body = body.trim_start(); + if let Some(rest) = body.strip_prefix("") + { + body = &rest[end + 2..]; + continue; + } + if let Some(rest) = body.strip_prefix("") + { + body = &rest[end + 3..]; + continue; + } + return body; + } +} + +/// Returns whether an HTTP request body should be treated as XML-RPC input. +fn looks_like_xmlrpc_request_body(body_text: &str) -> bool { + let body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}')); + body.starts_with(" Vec { + match prepare_rpc_http_request(config, request) { + Ok(Some(prepared)) => render_rpc_http_dispatch_response(dispatcher, config, prepared), + Ok(None) => http_no_content_response(), + Err(response) => response, + } +} + +/// Handles one HTTP RPC request while locking the dispatcher only for the actual dispatch path. +pub(super) fn handle_rpc_http_request_shared( + dispatcher: &Arc>, + config: &RpcServerConfig, + request: HttpRequest, +) -> io::Result> { + match prepare_rpc_http_request(config, request) { + Ok(Some(prepared)) => { + let mut dispatcher = dispatcher + .lock() + .map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?; + Ok(render_rpc_http_dispatch_response( + &mut dispatcher, + config, + prepared, + )) + } + Ok(None) => Ok(http_no_content_response()), + Err(response) => Ok(response), + } +} + +/// Parses and validates one HTTP RPC request before any dispatcher locking occurs. +fn prepare_rpc_http_request( + config: &RpcServerConfig, + request: HttpRequest, +) -> Result, Vec> { + if is_websocket_upgrade_candidate(&request) { + return Err(websocket_upgrade_response(config, &request)); + } + let path = normalized_rpc_path(&request.path); + let is_rpc_endpoint = matches!(path, "/jsonrpc" | "/rpc"); + if request.method.eq_ignore_ascii_case("OPTIONS") + && is_rpc_endpoint + && config.allow_origin.is_some() + { + return Err(rpc_http_no_content_response( + config, + header_value(&request.headers, "access-control-request-headers"), + )); + } + if request.method != "POST" { + return Err(http_response( + "405 Method Not Allowed", + "text/plain", + b"method not allowed", + )); + } + let content_type = header_value(&request.headers, "content-type").unwrap_or(""); + let body_text = String::from_utf8_lossy(&request.body); + let normalized_xml_body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}')); + let is_xml = contains_ascii_case_insensitive(content_type, "xml") + || path.ends_with(".xml") + || (path == "/rpc" && looks_like_xmlrpc_request_body(&body_text)) + || request.body.starts_with(b" request, + Err(error) => { + return Err(http_response( + "400 Bad Request", + "text/plain", + error.as_bytes(), + )); + } + }; + Ok(Some(PreparedHttpRpcRequest::Xml(request))) + } else { + if !config.enable_json_rpc { + return Err(http_response( + "404 Not Found", + "text/plain", + b"json-rpc disabled", + )); + } + let payload = match jsonrpc_payload_from_json(&body_text) { + Ok(payload) => payload, + Err(error) => { + let response = JsonRpcResponse::error( + None, + RpcError { + code: if error.contains("must not be empty") { + RpcErrorCode::InvalidRequest + } else { + RpcErrorCode::ParseError + }, + kind: crate::model::RpcErrorKind::InvalidParams, + message: error, + }, + ); + let body = jsonrpc_response_to_json(&response) + .unwrap_or_else(|render_error| format!(r#"{{"error":"{render_error}"}}"#)); + return Err(rpc_http_response( + config, + "200 OK", + "application/json", + body.as_bytes(), + )); + } + }; + Ok(Some(PreparedHttpRpcRequest::Json(payload))) + } +} + +/// Returns whether `needle` appears in `haystack`, ignoring ASCII case. +pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +/// Renders a prepared HTTP RPC request once dispatcher access has been acquired. +fn render_rpc_http_dispatch_response( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + prepared: PreparedHttpRpcRequest, +) -> Vec { + match prepared { + PreparedHttpRpcRequest::Xml(request) => { + let response = dispatch_xml_request(dispatcher, config, request); + let body = xmlrpc_method_response_to_xml(&response); + rpc_http_response(config, "200 OK", "text/xml", body.as_bytes()) + } + PreparedHttpRpcRequest::Json(payload) => { + render_json_http_dispatch_response(dispatcher, config, payload) + } + } +} + +/// Renders a prepared JSON-RPC HTTP request once dispatcher access has been acquired. +fn render_json_http_dispatch_response( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + payload: JsonRpcPayload, +) -> Vec { + match payload { + JsonRpcPayload::Single(request) => { + let response = dispatch_json_request(dispatcher, config, request); + if response.id.is_none() { + return http_no_content_response(); + } + let body = jsonrpc_response_to_json(&response) + .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)); + rpc_http_response(config, "200 OK", "application/json", body.as_bytes()) + } + JsonRpcPayload::Batch(items) => { + let mut responses = Vec::new(); + for item in items { + match item { + Ok(request) => { + let response = dispatch_json_request(dispatcher, config, request); + if response.id.is_some() { + responses.push(response); + } + } + Err(error) => responses.push(JsonRpcResponse::error( + None, + RpcError { + code: RpcErrorCode::InvalidRequest, + kind: crate::model::RpcErrorKind::InvalidParams, + message: error, + }, + )), + } + } + let body = jsonrpc_batch_response_to_json(&responses) + .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)); + rpc_http_response(config, "200 OK", "application/json", body.as_bytes()) + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/server/tests.rs b/crates/aria2-rust-pro-rpc/src/server/tests.rs new file mode 100644 index 0000000..cbbadc0 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/tests.rs @@ -0,0 +1,48 @@ +use std::{ + collections::BTreeMap, + io::Write, + net::{Shutdown, TcpListener, TcpStream}, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use super::{ + http_surface::{ + HttpRequest, dispatch_json_request, dispatch_xml_request, handle_rpc_http_request, + }, + websocket_session::flush_websocket_session_queue_shared, + websocket_surface::{ + flush_websocket_session_queue, handle_websocket_rpc_frame, + process_websocket_session_frames, read_websocket_frame, serve_upgraded_websocket_session, + websocket_frame_from_bytes, websocket_frame_to_bytes, + }, + *, +}; +use crate::{ + InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcNotificationEvent, + RpcNotificationKind, RpcValue, RpcWebSocketFrame, WebSocketSessionRegistry, XmlRpcMethodCall, + XmlRpcParam, XmlRpcValue, jsonrpc::JsonRpcId, +}; + +mod http_surface; +mod websocket_dispatch; +mod websocket_handshake; +mod websocket_session; + +#[doc(hidden)] +fn masked_text_frame_bytes(text: &str) -> Vec { + let payload = text.as_bytes(); + let mask = [0x11, 0x22, 0x33, 0x44]; + let masked_payload = payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index % mask.len()]) + .collect::>(); + let payload_len = + u8::try_from(payload.len()).expect("test websocket payload should fit in a short frame"); + let mut frame = vec![0x81, 0x80 | payload_len]; + frame.extend_from_slice(&mask); + frame.extend_from_slice(&masked_payload); + frame +} diff --git a/crates/aria2-rust-pro-rpc/src/server/tests/http_surface.rs b/crates/aria2-rust-pro-rpc/src/server/tests/http_surface.rs new file mode 100644 index 0000000..2649525 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/tests/http_surface.rs @@ -0,0 +1,519 @@ +use super::*; + +#[test] +fn serves_jsonrpc_version_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"version\"")); +} + +#[test] +fn serves_xmlrpc_version_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/rpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]), + body: br#"aria2.getVersion"#.to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("")); + assert!(response.contains("version")); +} + +#[test] +fn serves_xmlrpc_with_comments_and_spaced_empty_params_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + HttpRequest { + method: "POST".to_owned(), + path: "/rpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]), + body: br#" + + + + system.listNotifications + +"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("aria2.onDownloadStart")); + assert!(response.contains("aria2.onDownloadError")); +} + +#[test] +fn dispatch_json_rejects_bad_secret() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatch_json_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: Some(JsonRpcId::Number(1)), + method: RpcMethod::Aria2GetVersion.as_str().to_owned(), + params: vec![RpcValue::String("token:wrong".to_owned())], + meta: RpcMeta::default(), + }, + ); + + assert!(response.error.is_some()); +} + +#[test] +fn dispatch_json_allows_system_list_methods_without_secret() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatch_json_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: Some(JsonRpcId::Number(2)), + method: RpcMethod::SystemListMethods.as_str().to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ); + + assert!(response.error.is_none()); +} + +#[test] +fn dispatch_xml_allows_system_list_notifications_without_secret() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatch_xml_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + XmlRpcMethodCall { + method_name: "system.listNotifications".to_owned(), + params: Vec::new(), + meta: RpcMeta::default(), + }, + ); + + assert!(response.fault.is_none()); + match response.value { + Some(XmlRpcValue::Array(values)) => assert!(!values.is_empty()), + other => panic!("unexpected XML-RPC response value: {other:?}"), + } +} + +#[test] +fn dispatch_xml_rejects_invalid_secret_with_upstream_fault_shape() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = dispatch_xml_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + XmlRpcMethodCall { + method_name: "aria2.getVersion".to_owned(), + params: vec![XmlRpcParam { + value: XmlRpcValue::String("token:wrong".to_owned()), + }], + meta: RpcMeta::default(), + }, + ); + + assert!(response.value.is_none()); + let fault = response.fault.expect("expected XML-RPC fault"); + assert_eq!(fault.code, 1); + assert_eq!(fault.message, "RPC secret required or invalid token"); +} + +#[test] +fn serves_jsonrpc_batch_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([( + "content-type".to_owned(), + "application/json".to_owned(), + )]), + body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}]"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("[{")); + assert!(response.contains("\"id\":1")); + assert!(response.contains("\"id\":2")); + assert!(response.contains("\"version\"")); + assert!(response.contains("system.listMethods")); +} + +#[test] +fn serves_jsonrpc_batch_without_ids_as_invalid_request_errors() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([( + "content-type".to_owned(), + "application/json".to_owned(), + )]), + body: br#"[{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"system.listMethods","params":[]}]"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":null")); + assert!(response.contains("\"code\":-32600")); + assert!(response.contains("\"message\":\"Invalid Request.\"")); +} + +#[test] +fn serves_jsonrpc_batch_ignores_non_object_members_like_upstream_aria2() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7]"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":1")); + assert!(!response.contains("\"code\":-32600")); + assert!(!response.contains("jsonrpc request must be an object")); +} + +#[test] +fn serves_empty_jsonrpc_batch_as_empty_array_like_upstream_aria2() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br"[]".to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.ends_with("[]")); + assert!(!response.contains("204 No Content")); +} + +#[test] +fn serves_jsonrpc_batch_with_only_non_object_members_as_empty_array() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"[7,true,"noop"]"#.to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.ends_with("[]")); + assert!(!response.contains("204 No Content")); +} + +#[test] +fn serves_jsonrpc_list_notifications_with_upstream_names_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"jsonrpc":"2.0","id":7,"method":"system.listNotifications","params":[]}"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":7")); + assert!(response.contains("aria2.onDownloadStart")); + assert!(response.contains("aria2.onDownloadComplete")); + assert!(response.contains("aria2.onBtDownloadComplete")); +} + +#[test] +fn serves_jsonrpc_list_methods_without_secret_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"jsonrpc":"2.0","id":8,"method":"system.listMethods","params":[]}"#.to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":8")); + assert!(response.contains("aria2.addUri")); +} + +#[test] +fn serves_single_jsonrpc_request_without_id_as_invalid_request() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":null")); + assert!(response.contains("\"code\":-32600")); + assert!(response.contains("\"message\":\"Invalid Request.\"")); +} + +#[test] +fn serves_jsonrpc_named_params_as_invalid_params_with_same_id() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([( + "content-type".to_owned(), + "application/json".to_owned(), + )]), + body: br#"{"jsonrpc":"2.0","id":"named","method":"aria2.getVersion","params":{"foo":"bar"}}"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("\"id\":\"named\"")); + assert!(response.contains("\"code\":-32602")); + assert!(response.contains("\"message\":\"Invalid params.\"")); +} + +#[test] +fn serves_xmlrpc_list_notifications_without_secret_over_http() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + HttpRequest { + method: "POST".to_owned(), + path: "/rpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]), + body: br#"system.listNotifications"# + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("aria2.onDownloadStart")); + assert!(response.contains("aria2.onBtDownloadComplete")); +} + +#[test] +fn serves_xmlrpc_on_rpc_path_without_content_type_when_body_is_method_call() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/rpc".to_owned(), + headers: BTreeMap::new(), + body: br"aria2.getVersion" + .to_vec(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("")); + assert!(response.contains("version")); +} + +#[test] +fn serves_xmlrpc_with_case_insensitive_content_type_header_name_and_value() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([( + "Content-Type".to_owned(), + "Text/XML; charset=utf-8".to_owned(), + )]), + body: br"aria2.getVersion" + .to_vec(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("")); + assert!(response.contains("version")); +} + +#[test] +fn serves_jsonrpc_on_normalized_jsonrpc_path_with_query_and_trailing_slash() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc/?tm=1".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "text/plain".to_owned())]), + body: br#"{"jsonrpc":"2.0","id":21,"method":"aria2.getVersion","params":[]}"#.to_vec(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains(r#""id":21"#)); + assert!(response.contains(r#""version""#)); +} + +#[test] +fn serves_xmlrpc_on_normalized_rpc_path_without_content_type_when_body_has_bom_and_comment() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "POST".to_owned(), + path: "/rpc/?view=compat".to_owned(), + headers: BTreeMap::new(), + body: "\u{feff}aria2.getVersion" + .as_bytes() + .to_vec(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("")); + assert!(response.contains("version")); +} + +#[test] +fn serves_cors_preflight_for_jsonrpc_when_allow_origin_is_configured() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + allow_origin: Some("https://webui.example".to_owned()), + ..RpcServerConfig::default() + }, + HttpRequest { + method: "OPTIONS".to_owned(), + path: "/jsonrpc?cors=1".to_owned(), + headers: BTreeMap::from([ + ("origin".to_owned(), "https://webui.example".to_owned()), + ( + "access-control-request-method".to_owned(), + "POST".to_owned(), + ), + ( + "access-control-request-headers".to_owned(), + "content-type,x-requested-with".to_owned(), + ), + ]), + body: Vec::new(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("204 No Content")); + assert!(response.contains("Access-Control-Allow-Origin: https://webui.example")); + assert!(response.contains("Access-Control-Allow-Methods: POST, GET, OPTIONS")); + assert!(response.contains("Access-Control-Allow-Headers: content-type,x-requested-with")); +} + +#[test] +fn includes_allow_origin_header_on_http_jsonrpc_success_responses() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + allow_origin: Some("https://webui.example".to_owned()), + ..RpcServerConfig::default() + }, + HttpRequest { + method: "POST".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"jsonrpc":"2.0","id":22,"method":"aria2.getVersion","params":[]}"#.to_vec(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("200 OK")); + assert!(response.contains("Access-Control-Allow-Origin: https://webui.example")); + assert!(response.contains(r#""id":22"#)); +} diff --git a/crates/aria2-rust-pro-rpc/src/server/tests/websocket_dispatch.rs b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_dispatch.rs new file mode 100644 index 0000000..4451763 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_dispatch.rs @@ -0,0 +1,272 @@ +use super::*; + +#[test] +fn websocket_frame_roundtrips_masked_text_payload() { + let payload = br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#; + let mask = [0x11, 0x22, 0x33, 0x44]; + let masked_payload = payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index % 4]) + .collect::>(); + let mut frame = vec![0x81, 0x80 | (payload.len() as u8)]; + frame.extend_from_slice(&mask); + frame.extend_from_slice(&masked_payload); + + let parsed = websocket_frame_from_bytes(&frame).expect("masked text frame should parse"); + assert_eq!( + parsed, + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned() + ) + ); +} + +#[test] +fn websocket_frame_roundtrips_masked_binary_payload() { + let payload = br#"{"jsonrpc":"2.0","id":71,"method":"aria2.getVersion","params":[]}"#; + let mask = [0x11, 0x22, 0x33, 0x44]; + let masked_payload = payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index % 4]) + .collect::>(); + let mut frame = vec![0x82, 0x80 | (payload.len() as u8)]; + frame.extend_from_slice(&mask); + frame.extend_from_slice(&masked_payload); + + let parsed = websocket_frame_from_bytes(&frame).expect("masked binary frame should parse"); + assert_eq!(parsed, RpcWebSocketFrame::Binary(payload.to_vec())); +} + +#[test] +fn websocket_dispatches_text_request_and_returns_text_response() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":1"#)); + assert!(text.contains(r#""version""#)); + } + other => panic!("unexpected websocket rpc response: {other:?}"), + } +} + +#[test] +fn websocket_dispatches_binary_json_request_and_returns_text_response() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Binary( + br#"{"jsonrpc":"2.0","id":72,"method":"aria2.getVersion","params":[]}"#.to_vec(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":72"#)); + assert!(text.contains(r#""version""#)); + } + other => panic!("unexpected websocket binary rpc response: {other:?}"), + } +} + +#[test] +fn websocket_dispatches_system_list_methods_without_secret() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":11,"method":"system.listMethods","params":[]}"#.to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":11"#)); + assert!(text.contains("aria2.addUri")); + } + other => panic!("unexpected websocket system.listMethods response: {other:?}"), + } +} + +#[test] +fn websocket_dispatches_protected_method_with_secret_token() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":12,"method":"aria2.getVersion","params":["token:secret"]}"# + .to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":12"#)); + assert!(text.contains(r#""version""#)); + assert!(!text.contains(r#""code":-32000"#)); + } + other => panic!("unexpected websocket protected-method response: {other:?}"), + } +} + +#[test] +fn websocket_rejects_invalid_secret_with_error_frame() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":13,"method":"aria2.getVersion","params":["token:wrong"]}"# + .to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":13"#)); + assert!(text.contains(r#""code":-32000"#)); + assert!(text.contains("RPC secret required or invalid token")); + } + other => panic!("unexpected websocket invalid-secret response: {other:?}"), + } +} + +#[test] +fn websocket_batch_mixes_authorized_and_unauthorized_requests_independently() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig { + secret_token: Some("secret".to_owned()), + ..RpcServerConfig::default() + }, + RpcWebSocketFrame::Text( + concat!( + r#"[{"jsonrpc":"2.0","id":31,"method":"aria2.getVersion","params":["token:secret"]},"#, + r#"{"jsonrpc":"2.0","id":32,"method":"aria2.getVersion","params":["token:wrong"]},"#, + r#"{"jsonrpc":"2.0","id":33,"method":"system.listMethods","params":[]}]"# + ) + .to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.starts_with('[')); + assert!(text.contains(r#""id":31"#)); + assert!(text.contains(r#""id":32"#)); + assert!(text.contains(r#""id":33"#)); + assert!(text.contains(r#""version""#)); + assert!(text.contains("system.listMethods")); + assert!(text.contains(r#""code":-32000"#)); + assert!(text.contains("RPC secret required or invalid token")); + } + other => panic!("unexpected websocket batch response: {other:?}"), + } +} + +#[test] +fn websocket_batch_ignores_non_object_members_like_upstream_aria2() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Text( + r#"[{"jsonrpc":"2.0","id":41,"method":"aria2.getVersion","params":[]},7]"#.to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.starts_with('[')); + assert!(text.contains(r#""id":41"#)); + assert!(text.contains(r#""version""#)); + assert!(!text.contains(r#""code":-32600"#)); + } + other => panic!("unexpected websocket mixed batch response: {other:?}"), + } +} + +#[test] +fn websocket_empty_batch_returns_empty_array_response() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Text("[]".to_owned()), + ); + + assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned()))); +} + +#[test] +fn websocket_batch_with_only_non_object_members_returns_empty_array_response() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Text(r#"[7,true,"noop"]"#.to_owned()), + ); + + assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned()))); +} + +#[test] +fn websocket_request_without_id_returns_invalid_request_frame() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + ); + + match response { + Some(RpcWebSocketFrame::Text(text)) => { + assert!(text.contains(r#""id":null"#)); + assert!(text.contains(r#""code":-32600"#)); + assert!(text.contains(r#""message":"Invalid Request.""#)); + } + other => panic!("unexpected websocket invalid-request response: {other:?}"), + } +} + +#[test] +fn websocket_ping_returns_pong() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_websocket_rpc_frame( + &mut dispatcher, + &RpcServerConfig::default(), + RpcWebSocketFrame::Ping(vec![1, 2, 3, 4]), + ); + + assert_eq!(response, Some(RpcWebSocketFrame::Pong(vec![1, 2, 3, 4]))); +} + +#[test] +fn websocket_response_frame_serializes_as_text_opcode() { + let bytes = websocket_frame_to_bytes(&RpcWebSocketFrame::Text("ok".to_owned())); + assert_eq!(bytes, vec![0x81, 0x02, b'o', b'k']); +} diff --git a/crates/aria2-rust-pro-rpc/src/server/tests/websocket_handshake.rs b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_handshake.rs new file mode 100644 index 0000000..ac33c01 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_handshake.rs @@ -0,0 +1,205 @@ +use super::*; + +#[test] +fn serves_websocket_upgrade_handshake_on_jsonrpc_path() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "Upgrade".to_owned()), + ( + "sec-websocket-key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("sec-websocket-version".to_owned(), "13".to_owned()), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("101 Switching Protocols")); + assert!(response.contains("Upgrade: websocket")); + assert!(response.contains("Connection: Upgrade")); + assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); +} + +#[test] +fn serves_websocket_upgrade_with_case_insensitive_header_names_and_values() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("Upgrade".to_owned(), "WebSocket".to_owned()), + ("Connection".to_owned(), "keep-alive, Upgrade".to_owned()), + ( + "Sec-WebSocket-Key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("Sec-WebSocket-Version".to_owned(), "13".to_owned()), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("101 Switching Protocols")); + assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); +} + +#[test] +fn websocket_upgrade_does_not_echo_subprotocol_header() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "keep-alive, Upgrade".to_owned()), + ( + "sec-websocket-key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("sec-websocket-version".to_owned(), "13".to_owned()), + ( + "sec-websocket-protocol".to_owned(), + "graphql-ws, jsonrpc".to_owned(), + ), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("101 Switching Protocols")); + assert!(!response.contains("Sec-WebSocket-Protocol:")); +} + +#[test] +fn rejects_websocket_upgrade_when_disabled() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig::default(), + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "Upgrade".to_owned()), + ( + "sec-websocket-key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("sec-websocket-version".to_owned(), "13".to_owned()), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("404 Not Found")); + assert!(response.contains("websocket-rpc disabled")); +} + +#[test] +fn rejects_websocket_upgrade_with_missing_key() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "Upgrade".to_owned()), + ("sec-websocket-version".to_owned(), "13".to_owned()), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("400 Bad Request")); + assert!(response.contains("missing sec-websocket-key")); +} + +#[test] +fn rejects_websocket_upgrade_with_unsupported_version_using_426() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "Upgrade".to_owned()), + ( + "sec-websocket-key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("sec-websocket-version".to_owned(), "12".to_owned()), + ]), + body: Vec::new(), + }, + ); + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("426 Upgrade Required")); + assert!(response.contains("Sec-WebSocket-Version: 13")); + assert!(response.contains("unsupported websocket version")); +} + +#[test] +fn serves_websocket_upgrade_handshake_on_normalized_jsonrpc_path() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let response = handle_rpc_http_request( + &mut dispatcher, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + HttpRequest { + method: "GET".to_owned(), + path: "/jsonrpc/?transport=ws".to_owned(), + headers: BTreeMap::from([ + ("upgrade".to_owned(), "websocket".to_owned()), + ("connection".to_owned(), "keep-alive, Upgrade".to_owned()), + ( + "sec-websocket-key".to_owned(), + "dGhlIHNhbXBsZSBub25jZQ==".to_owned(), + ), + ("sec-websocket-version".to_owned(), "13".to_owned()), + ]), + body: Vec::new(), + }, + ); + + let response = String::from_utf8(response).expect("utf8 response"); + assert!(response.contains("101 Switching Protocols")); + assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); +} diff --git a/crates/aria2-rust-pro-rpc/src/server/tests/websocket_session.rs b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_session.rs new file mode 100644 index 0000000..657e50c --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/tests/websocket_session.rs @@ -0,0 +1,331 @@ +use super::*; + +#[test] +fn websocket_session_processes_multiple_requests_until_close() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let responses = process_websocket_session_frames( + &mut dispatcher, + &RpcServerConfig::default(), + vec![ + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + RpcWebSocketFrame::Ping(vec![9, 8, 7]), + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}"#.to_owned(), + ), + RpcWebSocketFrame::Close, + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":3,"method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + ], + ); + + assert_eq!(responses.len(), 4); + match &responses[0] { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains(r#""id":1"#)); + assert!(text.contains(r#""version""#)); + } + other => panic!("unexpected first websocket response: {other:?}"), + } + assert_eq!(responses[1], RpcWebSocketFrame::Pong(vec![9, 8, 7])); + match &responses[2] { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains(r#""id":2"#)); + assert!(text.contains("system.listMethods")); + } + other => panic!("unexpected third websocket response: {other:?}"), + } + assert_eq!(responses[3], RpcWebSocketFrame::Close); +} + +#[test] +fn websocket_session_returns_invalid_request_for_missing_id_and_continues() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let responses = process_websocket_session_frames( + &mut dispatcher, + &RpcServerConfig::default(), + vec![ + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + RpcWebSocketFrame::Text( + r#"{"jsonrpc":"2.0","id":4,"method":"aria2.getVersion","params":[]}"#.to_owned(), + ), + ], + ); + + assert_eq!(responses.len(), 2); + match &responses[0] { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains(r#""id":null"#)); + assert!(text.contains(r#""code":-32600"#)); + } + other => panic!("unexpected websocket invalid-request response: {other:?}"), + } + match &responses[1] { + RpcWebSocketFrame::Text(text) => assert!(text.contains(r#""id":4"#)), + other => panic!("unexpected websocket response after invalid request: {other:?}"), + } +} + +#[test] +fn flush_websocket_session_queue_writes_all_pending_frames_in_order() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind"); + let addr = listener + .local_addr() + .expect("listener address should resolve"); + let writer = thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("client should connect"); + let mut sessions = WebSocketSessionRegistry::default(); + sessions.connect("sess-1"); + sessions.queue_event_for_all(&RpcNotificationEvent { + kind: RpcNotificationKind::DownloadStarted, + method: String::new(), + gid: Some("abc".to_owned()), + payload: None, + meta: RpcMeta::default(), + }); + sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![7, 8, 9])); + flush_websocket_session_queue(&mut stream, &mut sessions, "sess-1") + .expect("queued websocket frames should flush"); + assert_eq!(sessions.pending_count("sess-1"), Some(0)); + }); + + let (mut accepted, _) = listener.accept().expect("server side should accept"); + let first = read_websocket_frame(&mut accepted) + .expect("first frame should read") + .expect("first frame should exist"); + let second = read_websocket_frame(&mut accepted) + .expect("second frame should read") + .expect("second frame should exist"); + + writer.join().expect("writer thread should join"); + match first { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadStart")); + assert!(text.contains(r#""gid":"abc""#)); + } + other => panic!("unexpected first flushed frame: {other:?}"), + } + assert_eq!(second, RpcWebSocketFrame::Ping(vec![7, 8, 9])); +} + +#[test] +fn flush_websocket_session_queue_shared_writes_pending_frames_without_borrowing_registry() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind"); + let addr = listener + .local_addr() + .expect("listener address should resolve"); + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + + let writer_sessions = Arc::clone(&sessions); + let writer = thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("client should connect"); + { + let mut sessions = writer_sessions + .lock() + .expect("sessions lock should succeed"); + sessions.connect("sess-1"); + sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![1])); + sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![2])); + } + flush_websocket_session_queue_shared(&mut stream, &writer_sessions, "sess-1") + .expect("shared queued websocket frames should flush"); + assert_eq!( + writer_sessions + .lock() + .expect("sessions lock should succeed") + .pending_count("sess-1"), + Some(0) + ); + }); + + let (mut accepted, _) = listener.accept().expect("server side should accept"); + let first = read_websocket_frame(&mut accepted) + .expect("first shared frame should read") + .expect("first shared frame should exist"); + let second = read_websocket_frame(&mut accepted) + .expect("second shared frame should read") + .expect("second shared frame should exist"); + + writer.join().expect("writer thread should join"); + assert_eq!(first, RpcWebSocketFrame::Ping(vec![1])); + assert_eq!(second, RpcWebSocketFrame::Ping(vec![2])); +} + +#[test] +fn upgraded_websocket_session_processes_masked_request_then_flushes_notification() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind"); + let addr = listener + .local_addr() + .expect("listener address should resolve"); + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + + let server_sessions = Arc::clone(&sessions); + let server = thread::spawn(move || { + let (stream, _) = listener + .accept() + .expect("server should accept websocket peer"); + serve_upgraded_websocket_session( + stream, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + Arc::new(Mutex::new(InProcessRpcDispatcher::new())), + server_sessions, + "sess-live-request".to_owned(), + ) + .expect("websocket session should complete cleanly"); + }); + + let mut client = TcpStream::connect(addr).expect("client should connect"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("client read timeout should configure"); + + for _ in 0..20 { + if sessions + .lock() + .expect("sessions lock should succeed") + .contains("sess-live-request") + { + break; + } + thread::sleep(Duration::from_millis(20)); + } + + let request = masked_text_frame_bytes( + r#"{"jsonrpc":"2.0","id":51,"method":"aria2.getVersion","params":[]}"#, + ); + client + .write_all(&request) + .expect("client websocket request should write"); + client + .flush() + .expect("client websocket request should flush"); + + let response = read_websocket_frame(&mut client) + .expect("client should receive websocket RPC response") + .expect("response frame should exist"); + match response { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains(r#""id":51"#)); + assert!(text.contains(r#""version""#)); + } + other => panic!("unexpected websocket response frame: {other:?}"), + } + + sessions + .lock() + .expect("sessions lock should succeed") + .queue_event_for_all(&RpcNotificationEvent { + kind: RpcNotificationKind::DownloadComplete, + method: String::new(), + gid: Some("after-request".to_owned()), + payload: None, + meta: RpcMeta::default(), + }); + + let notification = read_websocket_frame(&mut client) + .expect("client should receive websocket notification after request") + .expect("notification frame should exist"); + match notification { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadComplete")); + assert!(text.contains(r#""gid":"after-request""#)); + } + other => panic!("unexpected websocket notification frame: {other:?}"), + } + + client + .shutdown(Shutdown::Both) + .expect("client shutdown should succeed"); + drop(client); + server.join().expect("server thread should join"); + assert!( + !sessions + .lock() + .expect("sessions lock should succeed") + .contains("sess-live-request") + ); +} + +#[test] +fn upgraded_websocket_session_flushes_runtime_events_while_client_is_idle() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind"); + let addr = listener + .local_addr() + .expect("listener address should resolve"); + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + + let server_sessions = Arc::clone(&sessions); + let server = thread::spawn(move || { + let (stream, _) = listener + .accept() + .expect("server should accept websocket peer"); + serve_upgraded_websocket_session( + stream, + &RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + Arc::new(Mutex::new(InProcessRpcDispatcher::new())), + server_sessions, + "sess-live".to_owned(), + ) + .expect("websocket session should complete cleanly"); + }); + + let mut client = TcpStream::connect(addr).expect("client should connect"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("client read timeout should configure"); + + for _ in 0..20 { + if sessions + .lock() + .expect("sessions lock should succeed") + .contains("sess-live") + { + break; + } + thread::sleep(Duration::from_millis(20)); + } + + sessions + .lock() + .expect("sessions lock should succeed") + .queue_event_for_all(&RpcNotificationEvent { + kind: RpcNotificationKind::DownloadStarted, + method: String::new(), + gid: Some("idle-gid".to_owned()), + payload: None, + meta: RpcMeta::default(), + }); + + let frame = read_websocket_frame(&mut client) + .expect("idle client should receive queued websocket notification") + .expect("notification frame should exist"); + match frame { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadStart")); + assert!(text.contains(r#""gid":"idle-gid""#)); + } + other => panic!("unexpected idle websocket frame: {other:?}"), + } + + client + .shutdown(Shutdown::Both) + .expect("client shutdown should succeed"); + drop(client); + server.join().expect("server thread should join"); + assert!( + !sessions + .lock() + .expect("sessions lock should succeed") + .contains("sess-live") + ); +} diff --git a/crates/aria2-rust-pro-rpc/src/server/transport_runtime.rs b/crates/aria2-rust-pro-rpc/src/server/transport_runtime.rs new file mode 100644 index 0000000..864f066 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/transport_runtime.rs @@ -0,0 +1,463 @@ +use std::{ + io::{self, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, Mutex, + mpsc::{self, Receiver, Sender, TryRecvError}, + }, + thread, + time::Duration, +}; + +use crate::{ + InProcessRpcDispatcher, + websocket::{RuntimeEventWebSocketBridge, WebSocketSessionRegistry}, +}; + +use super::{ + RpcServerConfig, + http_surface::{ + handle_rpc_http_request_shared, is_websocket_upgrade_candidate, read_http_request, + }, + websocket_surface::serve_upgraded_websocket_session, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Transport-level tuning knobs for the RPC listener. +pub struct RpcServerTransportConfig { + /// Maximum simultaneously active connection workers allowed by the listener. + pub max_connections: usize, + /// Idle keep-alive window in seconds. + pub keep_alive_secs: u64, + /// Per-request timeout in seconds. + pub request_timeout_secs: u64, +} + +impl Default for RpcServerTransportConfig { + fn default() -> Self { + Self { + max_connections: 64, + keep_alive_secs: 30, + request_timeout_secs: 30, + } + } +} + +impl RpcServerTransportConfig { + /// Returns the effective per-request timeout, clamped away from zero. + fn request_timeout(self) -> Duration { + Duration::from_secs(self.request_timeout_secs.max(1)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Lightweight transport state exposed for tests and integration wiring. +pub struct RpcServerTransport { + /// Static transport configuration. + pub config: RpcServerTransportConfig, + /// Number of accepted connections observed so far. + pub accepted_connections: usize, +} + +impl RpcServerTransport { + #[must_use] + /// Creates a new transport state wrapper from the provided configuration. + pub fn new(config: RpcServerTransportConfig) -> Self { + Self { + config, + accepted_connections: 0, + } + } +} + +/// Serves RPC requests for the provided configuration and dispatcher. +/// +/// # Errors +/// +/// Returns an error when listener setup, socket accept, or transport I/O fails. +pub fn serve_rpc_listener( + listener: TcpListener, + config: RpcServerConfig, + dispatcher: Arc>, +) -> io::Result<()> { + RpcListenerRuntime::new( + listener, + config, + RpcServerTransport::new(RpcServerTransportConfig::default()), + dispatcher, + )? + .serve() +} + +/// Listener runtime that bounds accepted connection workers and WebSocket session state. +struct RpcListenerRuntime { + /// Bound TCP listener used to accept RPC connections. + listener: TcpListener, + /// Shared RPC server configuration applied to each accepted connection. + config: RpcServerConfig, + /// Transport-level counters and tuning knobs for the listener loop. + transport: RpcServerTransport, + /// Shared in-process RPC dispatcher used by HTTP and WebSocket requests. + dispatcher: Arc>, + /// Shared WebSocket session registry bridged from runtime events. + websocket_sessions: Arc>, + /// Monotonic identifier source for upgraded WebSocket sessions. + next_websocket_session_id: u64, + /// Number of connection workers currently running. + active_connection_workers: usize, + /// Receive side of worker completion notifications. + worker_results_rx: Receiver>, + /// Send side cloned into worker threads for completion notifications. + worker_results_tx: Sender>, +} + +impl RpcListenerRuntime { + /// Builds a nonblocking listener runtime and attaches the runtime event bridge. + fn new( + listener: TcpListener, + config: RpcServerConfig, + transport: RpcServerTransport, + dispatcher: Arc>, + ) -> io::Result { + listener.set_nonblocking(true)?; + let websocket_sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + let (worker_results_tx, worker_results_rx) = mpsc::channel(); + { + let mut dispatcher = dispatcher + .lock() + .map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?; + dispatcher.register_runtime_listener(RuntimeEventWebSocketBridge::new(Arc::clone( + &websocket_sessions, + ))); + } + Ok(Self { + listener, + config, + transport, + dispatcher, + websocket_sessions, + next_websocket_session_id: 1, + active_connection_workers: 0, + worker_results_rx, + worker_results_tx, + }) + } + + /// Serves accepted connections until the listener returns a terminal I/O error. + fn serve(mut self) -> io::Result<()> { + loop { + self.drain_completed_connection_workers()?; + if self.active_connection_workers >= self.connection_worker_limit() { + self.wait_for_connection_capacity()?; + continue; + } + match self.listener.accept() { + Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(error), + } + } + } + + /// Starts a worker thread for one accepted connection and tracks its completion. + fn spawn_ready_connection_worker(&mut self, stream: TcpStream) { + self.transport.accepted_connections = self.transport.accepted_connections.saturating_add(1); + self.active_connection_workers = self.active_connection_workers.saturating_add(1); + let session_id = format!("ws-{}", self.next_websocket_session_id); + self.next_websocket_session_id = self.next_websocket_session_id.saturating_add(1); + let request_timeout = self.transport.config.request_timeout(); + let config = self.config.clone(); + let dispatcher = Arc::clone(&self.dispatcher); + let websocket_sessions = Arc::clone(&self.websocket_sessions); + let worker_results_tx = self.worker_results_tx.clone(); + thread::spawn(move || { + let result = serve_ready_connection( + stream, + request_timeout, + &config, + dispatcher, + websocket_sessions, + session_id, + ); + let _ = worker_results_tx.send(result); + }); + } + + /// Returns the effective maximum number of concurrent connection workers. + fn connection_worker_limit(&self) -> usize { + self.transport.config.max_connections.max(1) + } + + /// Drains all currently completed worker results and propagates terminal errors. + fn drain_completed_connection_workers(&mut self) -> io::Result<()> { + loop { + match self.worker_results_rx.try_recv() { + Ok(result) => { + self.active_connection_workers = + self.active_connection_workers.saturating_sub(1); + result?; + } + Err(TryRecvError::Empty) => return Ok(()), + Err(TryRecvError::Disconnected) => { + if self.active_connection_workers == 0 { + return Ok(()); + } + return Err(io::Error::other("rpc worker result channel disconnected")); + } + } + } + } + + /// Waits briefly until at least one connection-worker slot becomes available. + fn wait_for_connection_capacity(&mut self) -> io::Result<()> { + match self + .worker_results_rx + .recv_timeout(Duration::from_millis(25)) + { + Ok(result) => { + self.active_connection_workers = self.active_connection_workers.saturating_sub(1); + result?; + self.drain_completed_connection_workers() + } + Err(mpsc::RecvTimeoutError::Timeout) => Ok(()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + if self.active_connection_workers == 0 { + return Ok(()); + } + Err(io::Error::other("rpc worker result channel disconnected")) + } + } + } + + #[cfg(test)] + fn serve_until_accept_count(mut self, accept_limit: usize) -> io::Result<()> { + loop { + self.drain_completed_connection_workers()?; + if self.transport.accepted_connections >= accept_limit { + if self.active_connection_workers == 0 { + return Ok(()); + } + self.wait_for_connection_capacity()?; + continue; + } + if self.active_connection_workers >= self.connection_worker_limit() { + self.wait_for_connection_capacity()?; + continue; + } + match self.listener.accept() { + Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error), + } + } + } +} + +/// Serves one already-accepted TCP connection through HTTP or WebSocket RPC handling. +fn serve_ready_connection( + mut stream: TcpStream, + request_timeout: Duration, + config: &RpcServerConfig, + dispatcher: Arc>, + websocket_sessions: Arc>, + session_id: String, +) -> io::Result<()> { + // The listener runs in nonblocking mode so accept loops can poll capacity, + // but worker-owned client streams should use blocking IO with timeouts. + stream.set_nonblocking(false)?; + stream.set_write_timeout(Some(request_timeout))?; + if let Some(request) = read_http_request(&mut stream, request_timeout)? { + let websocket_candidate = is_websocket_upgrade_candidate(&request); + let response = handle_rpc_http_request_shared(&dispatcher, config, request)?; + let _ = stream.write_all(&response); + let _ = stream.flush(); + + if websocket_candidate && response.starts_with(b"HTTP/1.1 101 ") { + serve_upgraded_websocket_session( + stream, + config, + dispatcher, + websocket_sessions, + session_id, + )?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{self, Read}, + net::Shutdown, + }; + + use super::*; + + fn http_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + } + + fn expected_http_response_len(buffer: &[u8]) -> Option { + let header_end = http_header_end(buffer)?; + let headers = std::str::from_utf8(&buffer[..header_end]) + .expect("http response headers should be valid utf8"); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("Content-Length") { + return Some( + value + .trim() + .parse::() + .expect("content-length should be a valid usize"), + ); + } + None + }) + .unwrap_or(0); + Some(header_end + content_length) + } + + fn read_http_response(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 512]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if let Some(expected_len) = expected_http_response_len(&buffer) + && buffer.len() >= expected_len + { + break; + } + } + Err(error) if error.kind() == io::ErrorKind::ConnectionReset => { + if let Some(expected_len) = expected_http_response_len(&buffer) + && buffer.len() >= expected_len + { + break; + } + if http_header_end(&buffer).is_some() { + break; + } + panic!( + "http response reset before completion: {error}; partial={}", + String::from_utf8_lossy(&buffer) + ); + } + Err(error) => panic!("http response should be readable: {error}"), + } + } + String::from_utf8(buffer).expect("response should be valid utf8") + } + + #[test] + fn accepted_websocket_worker_does_not_block_a_second_http_rpc_client() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind"); + let addr = listener + .local_addr() + .expect("listener address should resolve"); + let runtime = RpcListenerRuntime::new( + listener, + RpcServerConfig { + enable_websocket_rpc: true, + ..RpcServerConfig::default() + }, + RpcServerTransport::new(RpcServerTransportConfig { + max_connections: 2, + ..RpcServerTransportConfig::default() + }), + Arc::new(Mutex::new(InProcessRpcDispatcher::new())), + ) + .expect("listener runtime should initialize"); + + let server = thread::spawn(move || { + runtime + .serve_until_accept_count(2) + .expect("listener runtime should serve two clients"); + }); + + let mut websocket_client = + TcpStream::connect(addr).expect("websocket client should connect"); + websocket_client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("websocket client timeout should configure"); + websocket_client + .write_all( + concat!( + "GET /jsonrpc HTTP/1.1\r\n", + "Host: 127.0.0.1\r\n", + "Upgrade: websocket\r\n", + "Connection: Upgrade\r\n", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n", + "Sec-WebSocket-Version: 13\r\n", + "\r\n" + ) + .as_bytes(), + ) + .expect("websocket upgrade request should write"); + websocket_client + .flush() + .expect("websocket upgrade request should flush"); + let websocket_handshake = read_http_response(&mut websocket_client); + assert!( + websocket_handshake.starts_with("HTTP/1.1 101 "), + "unexpected websocket handshake: {websocket_handshake:?}" + ); + + let mut http_client = TcpStream::connect(addr).expect("http client should connect"); + http_client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("http client timeout should configure"); + let jsonrpc_body = r#"{"jsonrpc":"2.0","id":7,"method":"aria2.getVersion","params":[]}"#; + let http_request = format!( + concat!( + "POST /jsonrpc HTTP/1.1\r\n", + "Host: 127.0.0.1\r\n", + "Content-Type: application/json\r\n", + "Content-Length: {}\r\n", + "\r\n", + "{}" + ), + jsonrpc_body.len(), + jsonrpc_body + ); + http_client + .write_all(http_request.as_bytes()) + .expect("jsonrpc request should write"); + http_client.flush().expect("jsonrpc request should flush"); + let http_response = read_http_response(&mut http_client); + assert!( + http_response.contains("200 OK"), + "unexpected http status response: {http_response:?}" + ); + assert!( + http_response.contains(r#""id":7"#), + "missing jsonrpc id in response: {http_response:?}" + ); + assert!( + http_response.contains(r#""version""#), + "missing version payload in response: {http_response:?}" + ); + + http_client + .shutdown(Shutdown::Both) + .expect("http client shutdown should succeed"); + websocket_client + .shutdown(Shutdown::Both) + .expect("websocket client shutdown should succeed"); + drop(http_client); + drop(websocket_client); + server.join().expect("server thread should join"); + } +} diff --git a/crates/aria2-rust-pro-rpc/src/server/websocket_dispatch.rs b/crates/aria2-rust-pro-rpc/src/server/websocket_dispatch.rs new file mode 100644 index 0000000..6cc4399 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/websocket_dispatch.rs @@ -0,0 +1,177 @@ +use std::{ + io, + sync::{Arc, Mutex}, +}; + +use crate::{ + InProcessRpcDispatcher, + jsonrpc::{ + JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json, + jsonrpc_response_to_json, + }, + model::{RpcError, RpcErrorCode}, + websocket::RpcWebSocketFrame, +}; + +use super::{RpcServerConfig, http_surface::dispatch_json_request}; + +/// Converts a WebSocket parse failure into the JSON-RPC error frame expected by clients. +fn websocket_parse_error_response(message: String) -> RpcWebSocketFrame { + let response = JsonRpcResponse::error( + None, + RpcError { + code: if message.contains("must not be empty") { + RpcErrorCode::InvalidRequest + } else { + RpcErrorCode::ParseError + }, + kind: crate::model::RpcErrorKind::InvalidParams, + message, + }, + ); + let body = jsonrpc_response_to_json(&response) + .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)); + RpcWebSocketFrame::Text(body) +} + +/// Dispatches a text WebSocket frame as a JSON-RPC request or batch payload. +#[cfg(test)] +fn dispatch_websocket_text_frame( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + text: &str, +) -> Option { + match parse_websocket_jsonrpc_payload(text) { + Ok(payload) => dispatch_websocket_payload(dispatcher, config, payload), + Err(error) => Some(websocket_parse_error_response(error)), + } +} + +/// Parses a WebSocket JSON-RPC payload before any dispatcher locking occurs. +fn parse_websocket_jsonrpc_payload(text: &str) -> Result { + jsonrpc_payload_from_json(text) +} + +/// Dispatches a previously parsed WebSocket JSON-RPC payload. +fn dispatch_websocket_payload( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + payload: JsonRpcPayload, +) -> Option { + match payload { + JsonRpcPayload::Single(request) => { + let response = dispatch_json_request(dispatcher, config, request); + if response.id.is_none() { + return None; + } + let body = jsonrpc_response_to_json(&response) + .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)); + Some(RpcWebSocketFrame::Text(body)) + } + JsonRpcPayload::Batch(items) => { + let mut responses = Vec::new(); + for item in items { + match item { + Ok(request) => { + let response = dispatch_json_request(dispatcher, config, request); + if response.id.is_some() { + responses.push(response); + } + } + Err(error) => responses.push(JsonRpcResponse::error( + None, + RpcError { + code: RpcErrorCode::InvalidRequest, + kind: crate::model::RpcErrorKind::InvalidParams, + message: error, + }, + )), + } + } + let body = jsonrpc_batch_response_to_json(&responses) + .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)); + Some(RpcWebSocketFrame::Text(body)) + } + } +} + +/// Routes a decoded WebSocket frame through shared dispatcher access while keeping +/// control-frame and parse-error handling outside the dispatcher mutex. +pub(super) fn handle_websocket_rpc_frame_shared( + dispatcher: &Arc>, + config: &RpcServerConfig, + frame: RpcWebSocketFrame, +) -> io::Result> { + match frame { + RpcWebSocketFrame::Text(text) => { + dispatch_websocket_text_frame_shared(dispatcher, config, &text) + } + RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) { + Ok(text) => dispatch_websocket_text_frame_shared(dispatcher, config, &text), + Err(error) => Ok(Some(websocket_parse_error_response(error.to_string()))), + }, + RpcWebSocketFrame::Ping(payload) => Ok(Some(RpcWebSocketFrame::Pong(payload))), + RpcWebSocketFrame::Pong(_) => Ok(None), + RpcWebSocketFrame::Close => Ok(Some(RpcWebSocketFrame::Close)), + } +} + +/// Dispatches a text WebSocket frame while locking the dispatcher only for the +/// actual JSON-RPC execution path. +fn dispatch_websocket_text_frame_shared( + dispatcher: &Arc>, + config: &RpcServerConfig, + text: &str, +) -> io::Result> { + let payload = match parse_websocket_jsonrpc_payload(text) { + Ok(payload) => payload, + Err(error) => return Ok(Some(websocket_parse_error_response(error))), + }; + let mut dispatcher = dispatcher + .lock() + .map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?; + Ok(dispatch_websocket_payload(&mut dispatcher, config, payload)) +} + +/// Routes a decoded WebSocket frame through the RPC dispatch rules. +#[cfg(test)] +pub(super) fn handle_websocket_rpc_frame( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + frame: RpcWebSocketFrame, +) -> Option { + match frame { + RpcWebSocketFrame::Text(text) => dispatch_websocket_text_frame(dispatcher, config, &text), + RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) { + Ok(text) => dispatch_websocket_text_frame(dispatcher, config, &text), + Err(error) => Some(websocket_parse_error_response(error.to_string())), + }, + RpcWebSocketFrame::Ping(payload) => Some(RpcWebSocketFrame::Pong(payload)), + RpcWebSocketFrame::Pong(_) => None, + RpcWebSocketFrame::Close => Some(RpcWebSocketFrame::Close), + } +} + +#[cfg(test)] +/// Processes a sequence of test-only frames until one requests that the session close. +pub(super) fn process_websocket_session_frames( + dispatcher: &mut InProcessRpcDispatcher, + config: &RpcServerConfig, + frames: impl IntoIterator, +) -> Vec { + let mut responses = Vec::new(); + for frame in frames { + let should_close = matches!(frame, RpcWebSocketFrame::Close); + if let Some(response) = handle_websocket_rpc_frame(dispatcher, config, frame) { + let response_is_close = matches!(response, RpcWebSocketFrame::Close); + responses.push(response); + if response_is_close { + break; + } + } + if should_close { + break; + } + } + responses +} diff --git a/crates/aria2-rust-pro-rpc/src/server/websocket_handshake.rs b/crates/aria2-rust-pro-rpc/src/server/websocket_handshake.rs new file mode 100644 index 0000000..330f8de --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/websocket_handshake.rs @@ -0,0 +1,84 @@ +use base64::Engine; +use sha1::{Digest, Sha1}; + +use super::{ + RpcServerConfig, + http_surface::{ + HttpRequest, contains_ascii_case_insensitive, header_value, http_response, + http_response_with_headers, normalized_rpc_path, + }, +}; + +/// Computes the `Sec-WebSocket-Accept` value for a client-provided handshake key. +fn websocket_accept_value(key: &str) -> String { + let mut sha1 = Sha1::new(); + sha1.update(key.as_bytes()); + sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + let digest = sha1.finalize(); + base64::engine::general_purpose::STANDARD.encode(digest) +} + +/// Builds the HTTP upgrade response for a successful RPC WebSocket handshake. +pub(super) fn websocket_upgrade_response( + config: &RpcServerConfig, + request: &HttpRequest, +) -> Vec { + if !config.enable_websocket_rpc { + return http_response("404 Not Found", "text/plain", b"websocket-rpc disabled"); + } + if normalized_rpc_path(&request.path) != "/jsonrpc" { + return http_response("404 Not Found", "text/plain", b"unknown websocket path"); + } + if !request.method.eq_ignore_ascii_case("GET") { + return http_response( + "405 Method Not Allowed", + "text/plain", + b"websocket upgrade requires GET", + ); + } + let Some(key) = header_value(&request.headers, "sec-websocket-key") else { + return http_response( + "400 Bad Request", + "text/plain", + b"missing sec-websocket-key", + ); + }; + if !header_value(&request.headers, "upgrade") + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) + { + return http_response( + "400 Bad Request", + "text/plain", + b"missing websocket upgrade header", + ); + } + if !header_value(&request.headers, "connection") + .is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade")) + { + return http_response( + "400 Bad Request", + "text/plain", + b"missing connection upgrade header", + ); + } + if header_value(&request.headers, "sec-websocket-version") + .is_none_or(|value| value.trim() != "13") + { + return http_response_with_headers( + "426 Upgrade Required", + Some("text/plain"), + &[("Sec-WebSocket-Version", "13".to_owned())], + b"unsupported websocket version", + ); + } + + let mut headers = vec![ + ("Upgrade", "websocket".to_owned()), + ("Connection", "Upgrade".to_owned()), + ("Sec-WebSocket-Accept", websocket_accept_value(key)), + ]; + if let Some(origin) = &config.allow_origin { + headers.push(("Access-Control-Allow-Origin", origin.clone())); + } + http_response_with_headers("101 Switching Protocols", None, &headers, b"") +} diff --git a/crates/aria2-rust-pro-rpc/src/server/websocket_session.rs b/crates/aria2-rust-pro-rpc/src/server/websocket_session.rs new file mode 100644 index 0000000..81f53b9 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/websocket_session.rs @@ -0,0 +1,108 @@ +use std::{ + io::{self, Write}, + net::TcpStream, + sync::{Arc, Mutex}, +}; + +use crate::{ + InProcessRpcDispatcher, + websocket::{RpcWebSocketFrame, WebSocketSessionRegistry}, +}; + +use super::{ + RpcServerConfig, WEBSOCKET_IDLE_POLL_INTERVAL, + websocket_dispatch::handle_websocket_rpc_frame_shared, + websocket_wire::{read_websocket_frame, websocket_frame_to_bytes}, +}; + +#[cfg(test)] +/// Flushes any queued outbound frames for a WebSocket session to the client stream. +pub(super) fn flush_websocket_session_queue( + stream: &mut TcpStream, + sessions: &mut WebSocketSessionRegistry, + session_id: &str, +) -> io::Result { + let mut flushed = 0usize; + while let Some(frame) = sessions.pop_frame(session_id) { + let payload = websocket_frame_to_bytes(&frame); + stream.write_all(&payload)?; + stream.flush()?; + flushed += 1; + } + Ok(flushed) +} + +/// Flushes queued outbound frames without holding the shared session mutex across socket IO. +pub(super) fn flush_websocket_session_queue_shared( + stream: &mut TcpStream, + sessions: &Arc>, + session_id: &str, +) -> io::Result { + let frames = { + let mut sessions = sessions + .lock() + .map_err(|_| io::Error::other("websocket session mutex poisoned"))?; + sessions.drain_session_frames(session_id) + }; + let mut flushed = 0usize; + for frame in frames { + let payload = websocket_frame_to_bytes(&frame); + stream.write_all(&payload)?; + stream.flush()?; + flushed += 1; + } + Ok(flushed) +} + +/// Runs the request and notification loop for a successfully upgraded RPC WebSocket session. +pub(super) fn serve_upgraded_websocket_session( + mut stream: TcpStream, + config: &RpcServerConfig, + dispatcher: Arc>, + sessions: Arc>, + session_id: String, +) -> io::Result<()> { + stream.set_read_timeout(Some(WEBSOCKET_IDLE_POLL_INTERVAL))?; + if let Ok(mut sessions) = sessions.lock() { + sessions.connect(session_id.clone()); + } + + let result = loop { + let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id); + + let frame = match read_websocket_frame(&mut stream) { + Ok(frame) => frame, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Err(error) => break Err(error), + }; + + let Some(frame) = frame else { + break Ok(()); + }; + + let response_frame = handle_websocket_rpc_frame_shared(&dispatcher, config, frame)?; + if let Some(frame) = response_frame { + let should_close = matches!(frame, RpcWebSocketFrame::Close); + let payload = websocket_frame_to_bytes(&frame); + stream.write_all(&payload)?; + stream.flush()?; + let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id); + if should_close { + break Ok(()); + } + } + }; + + let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id); + if let Ok(mut sessions) = sessions.lock() { + sessions.disconnect(&session_id); + } + result +} diff --git a/crates/aria2-rust-pro-rpc/src/server/websocket_surface.rs b/crates/aria2-rust-pro-rpc/src/server/websocket_surface.rs new file mode 100644 index 0000000..30dfa43 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/websocket_surface.rs @@ -0,0 +1,16 @@ +//! WebSocket RPC surface facade. +//! +//! The HTTP server and tests historically imported the WebSocket helpers from +//! this module. Keep that boundary stable while the concrete responsibilities +//! live in narrower sibling modules. + +#[cfg(test)] +pub(super) use super::{ + websocket_dispatch::{handle_websocket_rpc_frame, process_websocket_session_frames}, + websocket_session::flush_websocket_session_queue, + websocket_wire::{read_websocket_frame, websocket_frame_from_bytes, websocket_frame_to_bytes}, +}; +pub(super) use super::{ + websocket_handshake::websocket_upgrade_response, + websocket_session::serve_upgraded_websocket_session, +}; diff --git a/crates/aria2-rust-pro-rpc/src/server/websocket_wire.rs b/crates/aria2-rust-pro-rpc/src/server/websocket_wire.rs new file mode 100644 index 0000000..9a034c6 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/server/websocket_wire.rs @@ -0,0 +1,177 @@ +use std::{ + io::{self, Read}, + net::TcpStream, +}; + +use crate::websocket::RpcWebSocketFrame; + +/// Encodes an internal WebSocket frame into wire bytes. +pub(super) fn websocket_frame_to_bytes(frame: &RpcWebSocketFrame) -> Vec { + let (opcode, payload): (u8, Vec) = match frame { + RpcWebSocketFrame::Text(text) => (0x1, text.as_bytes().to_vec()), + RpcWebSocketFrame::Binary(payload) => (0x2, payload.clone()), + RpcWebSocketFrame::Ping(payload) => (0x9, payload.clone()), + RpcWebSocketFrame::Pong(payload) => (0xA, payload.clone()), + RpcWebSocketFrame::Close => (0x8, Vec::new()), + }; + + let mut bytes = Vec::with_capacity(payload.len() + 10); + bytes.push(0x80 | opcode); + let payload_len = payload.len(); + if payload_len <= 125 { + bytes.push(payload_len as u8); + } else if u16::try_from(payload_len).is_ok() { + bytes.push(126); + bytes.extend_from_slice(&(payload_len as u16).to_be_bytes()); + } else { + bytes.push(127); + bytes.extend_from_slice(&(payload_len as u64).to_be_bytes()); + } + bytes.extend_from_slice(&payload); + bytes +} + +/// Decodes a complete WebSocket frame from wire bytes. +pub(super) fn websocket_frame_from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 2 { + return Err("websocket frame too short".to_owned()); + } + let fin = bytes[0] & 0x80 != 0; + if !fin { + return Err("fragmented websocket frames are unsupported".to_owned()); + } + let opcode = bytes[0] & 0x0F; + let masked = bytes[1] & 0x80 != 0; + let mut payload_len = usize::from(bytes[1] & 0x7F); + let mut offset = 2usize; + if payload_len == 126 { + if bytes.len() < offset + 2 { + return Err("truncated websocket extended length".to_owned()); + } + payload_len = usize::from(u16::from_be_bytes([bytes[offset], bytes[offset + 1]])); + offset += 2; + } else if payload_len == 127 { + if bytes.len() < offset + 8 { + return Err("truncated websocket extended length".to_owned()); + } + payload_len = u64::from_be_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) + .try_into() + .map_err(|_| "websocket frame too large".to_owned())?; + offset += 8; + } + + let mask = if masked { + if bytes.len() < offset + 4 { + return Err("truncated websocket mask".to_owned()); + } + let mask = [ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]; + offset += 4; + Some(mask) + } else { + None + }; + + if bytes.len() < offset + payload_len { + return Err("truncated websocket payload".to_owned()); + } + let mut payload = bytes[offset..offset + payload_len].to_vec(); + if let Some(mask) = mask { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % 4]; + } + } + + match opcode { + 0x1 => String::from_utf8(payload) + .map(RpcWebSocketFrame::Text) + .map_err(|error| error.to_string()), + 0x2 => Ok(RpcWebSocketFrame::Binary(payload)), + 0x8 => Ok(RpcWebSocketFrame::Close), + 0x9 => Ok(RpcWebSocketFrame::Ping(payload)), + 0xA => Ok(RpcWebSocketFrame::Pong(payload)), + _ => Err(format!("unsupported websocket opcode: {opcode}")), + } +} + +/// Reads an exact byte count while treating clean EOF as `false` rather than an error. +fn read_exact_or_eof(stream: &mut TcpStream, buffer: &mut [u8]) -> io::Result { + let mut offset = 0usize; + while offset < buffer.len() { + let read = stream.read(&mut buffer[offset..])?; + if read == 0 { + if offset == 0 { + return Ok(false); + } + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated websocket frame", + )); + } + offset += read; + } + Ok(true) +} + +/// Reads one WebSocket frame from the upgraded TCP stream. +pub(super) fn read_websocket_frame( + stream: &mut TcpStream, +) -> io::Result> { + let mut header = [0_u8; 2]; + if !read_exact_or_eof(stream, &mut header)? { + return Ok(None); + } + + let mut frame = header.to_vec(); + let payload_len_marker = usize::from(header[1] & 0x7F); + if payload_len_marker == 126 { + let mut extended = [0_u8; 2]; + read_exact_or_eof(stream, &mut extended)?; + frame.extend_from_slice(&extended); + } else if payload_len_marker == 127 { + let mut extended = [0_u8; 8]; + read_exact_or_eof(stream, &mut extended)?; + frame.extend_from_slice(&extended); + } + + if header[1] & 0x80 != 0 { + let mut mask = [0_u8; 4]; + read_exact_or_eof(stream, &mut mask)?; + frame.extend_from_slice(&mask); + } + + let payload_len = if payload_len_marker <= 125 { + payload_len_marker + } else if payload_len_marker == 126 { + usize::from(u16::from_be_bytes([frame[2], frame[3]])) + } else { + u64::from_be_bytes([ + frame[2], frame[3], frame[4], frame[5], frame[6], frame[7], frame[8], frame[9], + ]) + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "websocket frame too large"))? + }; + + let mut payload = vec![0_u8; payload_len]; + if payload_len > 0 { + read_exact_or_eof(stream, &mut payload)?; + frame.extend_from_slice(&payload); + } + + websocket_frame_from_bytes(&frame) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} diff --git a/crates/aria2-rust-pro-rpc/src/session.rs b/crates/aria2-rust-pro-rpc/src/session.rs new file mode 100644 index 0000000..7b2238b --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/session.rs @@ -0,0 +1,87 @@ +//! Session and token state used by the RPC transports. +use std::{ + collections::BTreeMap, + time::{Duration, SystemTime}, +}; + +use crate::model::RpcAuthContext; + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Authentication token issued to an RPC client session. +pub struct RpcAuthToken { + /// Opaque token value presented by the client. + pub value: String, + /// Time at which the token was issued. + pub issued_at: SystemTime, + /// Optional validity window for the token. + pub expires_in: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Snapshot of a connected RPC session. +pub struct RpcSessionInfo { + /// Stable session identifier. + pub session_id: String, + /// Remote peer address, if the transport exposes it. + pub peer_addr: Option, + /// Authentication state bound to the session. + pub auth: RpcAuthContext, + /// Time at which the session was created. + pub created_at: SystemTime, + /// Most recent activity timestamp. + pub last_seen_at: SystemTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Complete RPC session state, including token material. +pub struct RpcSession { + /// Session metadata visible to callers. + pub info: RpcSessionInfo, + /// Current authentication token, if one has been issued. + pub token: Option, +} + +#[derive(Debug, Default)] +/// In-memory store for active RPC sessions. +pub struct RpcSessionStore { + /// Session records keyed by their stable session identifier. + sessions: BTreeMap, +} + +impl RpcSessionStore { + /// Inserts or replaces a session keyed by its session identifier. + pub fn insert(&mut self, session: RpcSession) { + self.sessions + .insert(session.info.session_id.clone(), session); + } + + #[must_use] + /// Returns a shared reference to a session by identifier. + pub fn get(&self, session_id: &str) -> Option<&RpcSession> { + self.sessions.get(session_id) + } + + #[must_use] + /// Returns a mutable reference to a session by identifier. + pub fn get_mut(&mut self, session_id: &str) -> Option<&mut RpcSession> { + self.sessions.get_mut(session_id) + } + + #[must_use] + /// Removes and returns a session by identifier. + pub fn remove(&mut self, session_id: &str) -> Option { + self.sessions.remove(session_id) + } + + #[must_use] + /// Returns the number of stored sessions. + pub fn len(&self) -> usize { + self.sessions.len() + } + + #[must_use] + /// Returns whether the session store contains no sessions. + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} diff --git a/crates/aria2-rust-pro-rpc/src/websocket.rs b/crates/aria2-rust-pro-rpc/src/websocket.rs new file mode 100644 index 0000000..369c008 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/websocket.rs @@ -0,0 +1,46 @@ +//! WebSocket notification fan-out and framing helpers. +#![expect( + clippy::redundant_pub_crate, + reason = "the websocket surface keeps transport-specific names explicit for protocol parity" +)] + +use std::sync::{Arc, Mutex}; + +use aria2_rust_pro_core::{EventListener, RuntimeEvent}; + +/// Runtime-event bridge from core notifications into WebSocket queues. +mod bridge; +/// WebSocket notification event and frame model. +mod notification; +/// WebSocket subscription and connected-session registries. +mod registry; + +#[cfg(test)] +/// WebSocket notification and session queue tests. +mod tests; + +pub use self::notification::{RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame}; +pub use self::registry::{ + WebSocketNotificationRegistry, WebSocketSessionRegistry, WebSocketSessionState, + WebSocketSubscription, +}; + +#[derive(Debug, Clone)] +/// Runtime event listener that forwards core download events into WebSocket session queues. +pub(super) struct RuntimeEventWebSocketBridge( + /// Internal bridge implementation kept within the WebSocket facade. + bridge::RuntimeEventWebSocketBridge, +); + +impl RuntimeEventWebSocketBridge { + /// Builds a bridge backed by the shared WebSocket session registry. + pub(super) const fn new(sessions: Arc>) -> Self { + Self(bridge::RuntimeEventWebSocketBridge::new(sessions)) + } +} + +impl EventListener for RuntimeEventWebSocketBridge { + fn on_event(&mut self, event: &RuntimeEvent) { + self.0.on_event(event); + } +} diff --git a/crates/aria2-rust-pro-rpc/src/websocket/bridge.rs b/crates/aria2-rust-pro-rpc/src/websocket/bridge.rs new file mode 100644 index 0000000..ed11b6d --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/websocket/bridge.rs @@ -0,0 +1,65 @@ +use std::sync::{Arc, Mutex}; + +use aria2_rust_pro_core::{EventListener, RuntimeEvent, RuntimeEventKind}; + +use crate::model::RpcMeta; + +use super::{ + RpcNotificationKind, notification::RpcNotificationEvent, registry::WebSocketSessionRegistry, +}; + +#[derive(Debug, Clone)] +/// Runtime event listener that forwards core download events into WebSocket session queues. +pub(super) struct RuntimeEventWebSocketBridge { + /// Shared session registry updated whenever a compatible runtime event is observed. + sessions: Arc>, +} + +impl RuntimeEventWebSocketBridge { + /// Builds a bridge backed by the shared WebSocket session registry. + pub(super) const fn new(sessions: Arc>) -> Self { + Self { sessions } + } +} + +impl EventListener for RuntimeEventWebSocketBridge { + fn on_event(&mut self, event: &RuntimeEvent) { + let Some(kind) = runtime_event_to_rpc_notification_kind(event.kind) else { + return; + }; + let gid = event.gid.map(|gid| gid.to_string()); + let rpc_event = RpcNotificationEvent { + kind, + method: String::new(), + gid, + payload: None, + meta: RpcMeta::default(), + }; + if let Ok(mut sessions) = self.sessions.lock() { + sessions.queue_broadcast_frame(rpc_event.to_websocket_frame()); + } + } +} + +/// Maps core runtime events onto the subset of WebSocket notifications exposed over RPC. +const fn runtime_event_to_rpc_notification_kind( + kind: RuntimeEventKind, +) -> Option { + match kind { + RuntimeEventKind::DownloadAdded + | RuntimeEventKind::DownloadResumed + | RuntimeEventKind::OptionChanged + | RuntimeEventKind::SessionSaving + | RuntimeEventKind::SessionSaved + | RuntimeEventKind::ShutdownRequested + | RuntimeEventKind::ForceShutdownRequested + | RuntimeEventKind::SchedulerTick + | RuntimeEventKind::StatisticsUpdated + | RuntimeEventKind::PieceUpdated => None, + RuntimeEventKind::DownloadStarted => Some(RpcNotificationKind::DownloadStarted), + RuntimeEventKind::DownloadPaused => Some(RpcNotificationKind::DownloadPaused), + RuntimeEventKind::DownloadRemoved => Some(RpcNotificationKind::DownloadStopped), + RuntimeEventKind::DownloadCompleted => Some(RpcNotificationKind::DownloadComplete), + RuntimeEventKind::DownloadErrored => Some(RpcNotificationKind::DownloadError), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/websocket/notification.rs b/crates/aria2-rust-pro-rpc/src/websocket/notification.rs new file mode 100644 index 0000000..8ef1c44 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/websocket/notification.rs @@ -0,0 +1,189 @@ +use std::collections::BTreeMap; + +use crate::model::{RpcMeta, RpcValue}; + +/// Maximum queued outbound WebSocket frames retained per connected session. +pub(super) const MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION: usize = 256; +/// Maximum bridged runtime-event broadcast frames retained before session fan-out. +pub(super) const MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES: usize = 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// Notification kinds that can be bridged onto WebSocket sessions. +pub enum RpcNotificationKind { + /// A download transitioned into the active state. + DownloadStarted, + /// A download was paused. + DownloadPaused, + /// A download was stopped or removed. + DownloadStopped, + /// A download completed successfully. + DownloadComplete, + /// A download ended in error. + DownloadError, + /// A download was removed. + DownloadRemoved, + /// A download entered the waiting queue. + DownloadWaiting, + /// A waiting download became active again. + DownloadActive, + /// A `BitTorrent` download completed. + DownloadBtDownloadComplete, + /// Synthetic notification for version polling. + SystemVersion, + /// Synthetic notification for method-list polling. + SystemListMethods, + /// Synthetic notification for notification-list polling. + SystemListNotifications, +} + +#[derive(Debug, Clone, PartialEq)] +/// Notification payload bridged to WebSocket clients. +pub struct RpcNotificationEvent { + /// Logical notification kind. + pub kind: RpcNotificationKind, + /// Explicit method name override, if present. + pub method: String, + /// Download gid associated with the notification, if any. + pub gid: Option, + /// Additional notification payload. + pub payload: Option, + /// Supplemental metadata. + pub meta: RpcMeta, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Minimal WebSocket frame shapes used by the RPC server. +pub enum RpcWebSocketFrame { + /// UTF-8 text frame. + Text(String), + /// Binary frame carrying JSON-RPC bytes. + Binary(Vec), + /// Ping control frame. + Ping(Vec), + /// Pong control frame. + Pong(Vec), + /// Close control frame. + Close, +} + +impl RpcNotificationKind { + /// Returns the canonical aria2-compatible method name for this notification. + #[must_use] + pub const fn method_name(self) -> &'static str { + match self { + Self::DownloadStarted | Self::DownloadActive => "aria2.onDownloadStart", + Self::DownloadPaused | Self::DownloadWaiting => "aria2.onDownloadPause", + Self::DownloadStopped | Self::DownloadRemoved => "aria2.onDownloadStop", + Self::DownloadComplete => "aria2.onDownloadComplete", + Self::DownloadError => "aria2.onDownloadError", + Self::DownloadBtDownloadComplete => "aria2.onBtDownloadComplete", + Self::SystemVersion => "aria2.getVersion", + Self::SystemListMethods => "system.listMethods", + Self::SystemListNotifications => "system.listNotifications", + } + } +} + +impl RpcNotificationEvent { + #[must_use] + /// Returns the effective WebSocket method name for the event. + pub fn websocket_method_name(&self) -> &str { + if self.method.is_empty() { + self.kind.method_name() + } else { + &self.method + } + } + + #[must_use] + /// Converts the event into a text WebSocket frame. + pub fn to_websocket_frame(&self) -> RpcWebSocketFrame { + RpcWebSocketFrame::Text(self.to_websocket_json()) + } + + #[must_use] + /// Renders the event into a JSON-RPC notification string. + pub fn to_websocket_json(&self) -> String { + let method = self.websocket_method_name(); + let params = websocket_notification_params(self.gid.as_deref(), self.payload.as_ref()); + let params = rpc_value_to_json(&RpcValue::Array(params)); + format!( + "{{\"jsonrpc\":\"2.0\",\"method\":\"{}\",\"params\":{}}}", + escape_json(method), + params + ) + } +} + +/// Builds the JSON-RPC `params` array for a bridged WebSocket notification event. +pub(super) fn websocket_notification_params( + gid: Option<&str>, + payload: Option<&RpcValue>, +) -> Vec { + if let Some(RpcValue::Array(items)) = payload { + return items.clone(); + } + + let mut event_spec = BTreeMap::new(); + if let Some(gid) = gid { + event_spec.insert("gid".to_owned(), RpcValue::String(gid.to_owned())); + } + + if let Some(RpcValue::Object(map)) = payload { + for (key, value) in map { + event_spec.insert(key.clone(), value.clone()); + } + } + + vec![RpcValue::Object(event_spec)] +} + +/// Renders a transport-neutral RPC value into compact JSON text for WebSocket frames. +pub(super) fn rpc_value_to_json(value: &RpcValue) -> String { + match value { + RpcValue::Null => "null".to_owned(), + RpcValue::Bool(value) => value.to_string(), + RpcValue::Number(value) => value.to_string(), + RpcValue::String(value) => format!("\"{}\"", escape_json(value)), + RpcValue::Array(values) => { + let items = values + .iter() + .map(rpc_value_to_json) + .collect::>() + .join(","); + format!("[{items}]") + } + RpcValue::Object(map) => { + let members = map + .iter() + .map(|(key, value)| { + format!("\"{}\":{}", escape_json(key), rpc_value_to_json(value)) + }) + .collect::>() + .join(","); + format!("{{{members}}}") + } + } +} + +/// Escapes a string for safe embedding in generated JSON text. +pub(super) fn escape_json(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + '\u{08}' => escaped.push_str("\\b"), + '\u{0C}' => escaped.push_str("\\f"), + ch if ch.is_control() => { + use std::fmt::Write as _; + let _ = write!(escaped, "\\u{:04x}", u32::from(ch)); + } + ch => escaped.push(ch), + } + } + escaped +} diff --git a/crates/aria2-rust-pro-rpc/src/websocket/registry.rs b/crates/aria2-rust-pro-rpc/src/websocket/registry.rs new file mode 100644 index 0000000..b3229f8 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/websocket/registry.rs @@ -0,0 +1,219 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use super::notification::{ + MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION, + RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Subscription descriptor for a WebSocket notification consumer. +pub struct WebSocketSubscription { + /// Stable subscription identifier. + pub id: String, + /// Notification kind being subscribed to. + pub kind: RpcNotificationKind, + /// Client-visible topic string. + pub topic: String, +} + +#[derive(Debug, Default)] +/// Registry of active WebSocket notification subscriptions. +pub struct WebSocketNotificationRegistry { + /// Subscription descriptors keyed by subscription identifier. + subscriptions: BTreeMap, + /// Reverse index from notification kind to subscribed identifiers. + by_kind: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Per-session queued WebSocket state. +pub struct WebSocketSessionState { + /// Stable session identifier. + pub id: String, + /// Frames waiting to be written to the socket for this session. + pending_frames: VecDeque, +} + +#[derive(Debug, Default)] +/// Registry of connected WebSocket sessions. +pub struct WebSocketSessionRegistry { + /// Session entries keyed by their stable session identifier. + sessions: BTreeMap, + /// Bounded runtime-event broadcast ingress awaiting session fan-out. + pending_broadcast_frames: VecDeque, +} + +impl WebSocketNotificationRegistry { + /// Registers a new subscription. + pub fn subscribe(&mut self, subscription: WebSocketSubscription) { + self.by_kind + .entry(subscription.kind) + .or_default() + .insert(subscription.id.clone()); + self.subscriptions + .insert(subscription.id.clone(), subscription); + } + + /// Removes a subscription by identifier. + pub fn unsubscribe(&mut self, subscription_id: &str) { + if let Some(subscription) = self.subscriptions.remove(subscription_id) + && let Some(ids) = self.by_kind.get_mut(&subscription.kind) + { + ids.remove(subscription_id); + } + } + + #[must_use] + /// Returns all subscriptions for the provided notification kind. + pub fn subscriptions_for(&self, kind: RpcNotificationKind) -> Vec<&WebSocketSubscription> { + self.by_kind + .get(&kind) + .into_iter() + .flat_map(|ids| ids.iter()) + .filter_map(|id| self.subscriptions.get(id)) + .collect() + } + + #[must_use] + /// Returns the number of registered subscriptions. + pub fn len(&self) -> usize { + self.subscriptions.len() + } + + #[must_use] + /// Returns whether the registry contains no subscriptions. + pub fn is_empty(&self) -> bool { + self.subscriptions.is_empty() + } + + /// Builds a copy-on-write frame list for a single event fan-out. + #[must_use] + pub fn frames_for_event( + &self, + event: &RpcNotificationEvent, + ) -> Vec<(String, RpcWebSocketFrame)> { + let frame = event.to_websocket_frame(); + self.subscriptions_for(event.kind) + .into_iter() + .map(|subscription| (subscription.id.clone(), frame.clone())) + .collect() + } +} + +impl WebSocketSessionRegistry { + /// Connects or reuses a session entry for the provided identifier. + pub fn connect(&mut self, session_id: impl Into) { + let session_id = session_id.into(); + self.sessions + .entry(session_id.clone()) + .or_insert_with(|| WebSocketSessionState { + id: session_id, + pending_frames: VecDeque::new(), + }); + } + + /// Disconnects a session and drops its pending queue. + pub fn disconnect(&mut self, session_id: &str) { + self.sessions.remove(session_id); + } + + #[must_use] + /// Returns the number of connected sessions. + pub fn len(&self) -> usize { + self.sessions.len() + } + + #[must_use] + /// Returns whether the registry contains no connected sessions. + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + #[must_use] + /// Returns whether the registry contains the provided session id. + pub fn contains(&self, session_id: &str) -> bool { + self.sessions.contains_key(session_id) + } + + #[must_use] + /// Returns the connected session identifiers in key order. + pub fn session_ids(&self) -> Vec { + self.sessions.keys().cloned().collect() + } + + /// Queues a notification frame for every connected session. + pub fn queue_event_for_all(&mut self, event: &RpcNotificationEvent) { + let frame = event.to_websocket_frame(); + for session in self.sessions.values_mut() { + enqueue_session_frame(session, frame.clone()); + } + } + + /// Queues one bridged broadcast frame for later session fan-out. + pub fn queue_broadcast_frame(&mut self, frame: RpcWebSocketFrame) { + if self.sessions.is_empty() { + return; + } + if self.pending_broadcast_frames.len() >= MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES { + let _ = self.pending_broadcast_frames.pop_front(); + } + self.pending_broadcast_frames.push_back(frame); + } + + /// Queues a frame for a single session and reports whether it existed. + pub fn queue_frame_for_session(&mut self, session_id: &str, frame: RpcWebSocketFrame) -> bool { + let Some(session) = self.sessions.get_mut(session_id) else { + return false; + }; + enqueue_session_frame(session, frame); + true + } + + #[must_use] + /// Returns the pending frame count for a session, if it exists. + pub fn pending_count(&self, session_id: &str) -> Option { + self.sessions + .get(session_id) + .map(|session| session.pending_frames.len()) + } + + #[must_use] + /// Returns the pending bridged broadcast frame count. + pub fn pending_broadcast_count(&self) -> usize { + self.pending_broadcast_frames.len() + } + + /// Fans out all pending bridged broadcast frames into the per-session queues. + pub fn drain_broadcast_frames_into_sessions(&mut self) { + while let Some(frame) = self.pending_broadcast_frames.pop_front() { + for session in self.sessions.values_mut() { + enqueue_session_frame(session, frame.clone()); + } + } + } + + /// Drains all queued frames for one session after applying pending broadcast fan-out. + pub fn drain_session_frames(&mut self, session_id: &str) -> Vec { + self.drain_broadcast_frames_into_sessions(); + self.sessions + .get_mut(session_id) + .map(|session| session.pending_frames.drain(..).collect()) + .unwrap_or_default() + } + + /// Pops the oldest pending frame for a session. + pub fn pop_frame(&mut self, session_id: &str) -> Option { + self.drain_broadcast_frames_into_sessions(); + self.sessions + .get_mut(session_id) + .and_then(|session| session.pending_frames.pop_front()) + } +} + +/// Adds a frame to the session queue while enforcing the bounded backpressure policy. +fn enqueue_session_frame(session: &mut WebSocketSessionState, frame: RpcWebSocketFrame) { + if session.pending_frames.len() >= MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION { + let _ = session.pending_frames.pop_front(); + } + session.pending_frames.push_back(frame); +} diff --git a/crates/aria2-rust-pro-rpc/src/websocket/tests.rs b/crates/aria2-rust-pro-rpc/src/websocket/tests.rs new file mode 100644 index 0000000..2cd293c --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/websocket/tests.rs @@ -0,0 +1,397 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use aria2_rust_pro_core::{ + DownloadEngine, DownloadId, EventListener, RuntimeEvent, RuntimeEventKind, +}; + +use super::notification::{ + MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION, +}; +use super::*; +use crate::{InProcessRpcDispatcher, JsonRpcRequest, RpcValue}; + +#[test] +/// Verifies that notification kinds keep the upstream aria2 WebSocket method names. +fn notification_kind_uses_upstream_websocket_method_names() { + assert_eq!( + RpcNotificationKind::DownloadStarted.method_name(), + "aria2.onDownloadStart" + ); + assert_eq!( + RpcNotificationKind::DownloadBtDownloadComplete.method_name(), + "aria2.onBtDownloadComplete" + ); +} + +#[test] +/// Verifies that notification events render the upstream `JSON-RPC` notification shape. +fn websocket_event_renders_upstream_jsonrpc_notification_shape() { + let event = RpcNotificationEvent { + kind: RpcNotificationKind::DownloadComplete, + method: String::new(), + gid: Some("a1b2c3".to_owned()), + payload: None, + meta: crate::model::RpcMeta::default(), + }; + + assert_eq!( + event.to_websocket_json(), + "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}" + ); + assert_eq!( + event.to_websocket_frame(), + RpcWebSocketFrame::Text( + "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}".to_owned() + ) + ); +} + +#[test] +/// Verifies that object payloads merge with the gid field in notification output. +fn websocket_event_merges_gid_with_object_payload() { + let event = RpcNotificationEvent { + kind: RpcNotificationKind::DownloadError, + method: String::new(), + gid: Some("deadbeef".to_owned()), + payload: Some(RpcValue::Object(BTreeMap::from([( + "status".to_owned(), + RpcValue::String("error".to_owned()), + )]))), + meta: crate::model::RpcMeta::default(), + }; + + assert_eq!( + event.to_websocket_json(), + "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadError\",\"params\":[{\"gid\":\"deadbeef\",\"status\":\"error\"}]}" + ); +} + +#[test] +/// Verifies that one notification frame is emitted for each matching subscription. +fn registry_emits_one_frame_per_matching_subscription() { + let mut registry = WebSocketNotificationRegistry::default(); + registry.subscribe(WebSocketSubscription { + id: "sub-a".to_owned(), + kind: RpcNotificationKind::DownloadStarted, + topic: "aria2.onDownloadStart".to_owned(), + }); + registry.subscribe(WebSocketSubscription { + id: "sub-b".to_owned(), + kind: RpcNotificationKind::DownloadStarted, + topic: "aria2.onDownloadStart".to_owned(), + }); + registry.subscribe(WebSocketSubscription { + id: "sub-c".to_owned(), + kind: RpcNotificationKind::DownloadComplete, + topic: "aria2.onDownloadComplete".to_owned(), + }); + + let event = RpcNotificationEvent { + kind: RpcNotificationKind::DownloadStarted, + method: String::new(), + gid: Some("feedface".to_owned()), + payload: None, + meta: crate::model::RpcMeta::default(), + }; + + let frames = registry.frames_for_event(&event); + assert_eq!(frames.len(), 2); + assert!(frames.iter().any(|(id, _)| id == "sub-a")); + assert!(frames.iter().any(|(id, _)| id == "sub-b")); + assert!( + frames + .iter() + .all(|(_, frame)| matches!(frame, RpcWebSocketFrame::Text(_))) + ); +} + +#[test] +/// Verifies that broadcast queueing fans notification frames out to every connected session. +fn session_registry_broadcasts_notification_frames_to_all_sessions() { + let mut registry = WebSocketSessionRegistry::default(); + registry.connect("sess-a"); + registry.connect("sess-b"); + + let event = RpcNotificationEvent { + kind: RpcNotificationKind::DownloadComplete, + method: String::new(), + gid: Some("abc123".to_owned()), + payload: None, + meta: crate::model::RpcMeta::default(), + }; + + registry.queue_event_for_all(&event); + + assert_eq!(registry.pending_count("sess-a"), Some(1)); + assert_eq!(registry.pending_count("sess-b"), Some(1)); + assert_eq!( + registry.pop_frame("sess-a"), + Some(RpcWebSocketFrame::Text( + "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned() + )) + ); + assert_eq!( + registry.pop_frame("sess-b"), + Some(RpcWebSocketFrame::Text( + "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned() + )) + ); +} + +#[test] +/// Verifies that bridged broadcast ingress stays bounded and drops the oldest frame first. +fn session_registry_enforces_bounded_broadcast_ingress_backpressure() { + let mut registry = WebSocketSessionRegistry::default(); + registry.connect("sess-a"); + + for index in 0..(MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES + 8) { + registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![ + u8::try_from(index % 256).expect("byte should fit"), + ])); + } + + assert_eq!( + registry.pending_broadcast_count(), + MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES + ); + assert_eq!( + registry.pop_frame("sess-a"), + Some(RpcWebSocketFrame::Ping(vec![8])), + "oldest bridged broadcast frames should be dropped first once the ingress limit is reached" + ); +} + +#[test] +/// Verifies that bridged broadcast ingress fans out only when a session drain occurs. +fn session_registry_drains_broadcast_ingress_into_connected_sessions() { + let mut registry = WebSocketSessionRegistry::default(); + registry.connect("sess-a"); + registry.connect("sess-b"); + + registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![4, 2])); + + assert_eq!(registry.pending_count("sess-a"), Some(0)); + assert_eq!(registry.pending_count("sess-b"), Some(0)); + assert_eq!(registry.pending_broadcast_count(), 1); + + let sess_a_frames = registry.drain_session_frames("sess-a"); + assert_eq!(registry.pending_broadcast_count(), 0); + assert_eq!(sess_a_frames, vec![RpcWebSocketFrame::Ping(vec![4, 2])]); + assert_eq!( + registry.pop_frame("sess-b"), + Some(RpcWebSocketFrame::Ping(vec![4, 2])) + ); +} + +#[test] +/// Verifies targeted queueing, missing-session rejection, and disconnect behavior. +fn session_registry_supports_targeted_queue_and_disconnect() { + let mut registry = WebSocketSessionRegistry::default(); + registry.connect("sess-a"); + registry.connect("sess-b"); + + assert!(registry.queue_frame_for_session("sess-a", RpcWebSocketFrame::Ping(vec![1, 2, 3]),)); + assert!(!registry.queue_frame_for_session("sess-missing", RpcWebSocketFrame::Ping(vec![9]),)); + + assert_eq!(registry.pending_count("sess-a"), Some(1)); + assert_eq!(registry.pending_count("sess-b"), Some(0)); + assert_eq!( + registry.pop_frame("sess-a"), + Some(RpcWebSocketFrame::Ping(vec![1, 2, 3])) + ); + + registry.disconnect("sess-b"); + assert!(!registry.contains("sess-b")); + assert_eq!(registry.len(), 1); +} + +#[test] +/// Verifies that the per-session queue is bounded and drops the oldest frame under pressure. +fn session_registry_enforces_bounded_pending_frame_backpressure() { + let mut registry = WebSocketSessionRegistry::default(); + registry.connect("sess-a"); + + for index in 0..(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION + 8) { + assert!(registry.queue_frame_for_session( + "sess-a", + RpcWebSocketFrame::Ping(vec![u8::try_from(index % 256).expect("byte should fit")]), + )); + } + + assert_eq!( + registry.pending_count("sess-a"), + Some(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION) + ); + assert_eq!( + registry.pop_frame("sess-a"), + Some(RpcWebSocketFrame::Ping(vec![8])), + "oldest queued frames should be dropped first once the per-session limit is reached" + ); +} + +#[test] +/// Verifies that the runtime-event bridge queues real completion events into session frames. +fn runtime_event_bridge_queues_real_download_events_into_sessions() { + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + sessions + .lock() + .expect("sessions lock should succeed") + .connect("sess-a"); + let mut bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)); + + bridge.on_event( + &RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(DownloadId::new(0x2a)), + ); + + assert_eq!( + sessions + .lock() + .expect("sessions lock should succeed") + .pending_broadcast_count(), + 1 + ); + let frame = sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .expect("download completion should queue a notification frame"); + match frame { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadComplete")); + assert!(text.contains(r#""gid":"000000000000002a""#)); + } + other => panic!("unexpected bridged notification frame: {other:?}"), + } +} + +#[test] +/// Verifies that `addUri` registration alone does not emit a start notification. +fn runtime_event_bridge_does_not_treat_add_uri_as_download_start() { + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + sessions + .lock() + .expect("sessions lock should succeed") + .connect("sess-a"); + let bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)); + let mut dispatcher = InProcessRpcDispatcher::new(); + dispatcher.register_runtime_listener(bridge); + + let response = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: Some(crate::jsonrpc::JsonRpcId::Number(1)), + method: "aria2.addUri".to_owned(), + params: vec![RpcValue::String("https://example.org/file.iso".to_owned())], + meta: crate::model::RpcMeta::default(), + }); + assert!(response.error.is_none()); + + assert!( + sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .is_none(), + "addUri should not emit aria2.onDownloadStart before the download actually starts" + ); +} + +#[test] +/// Verifies that start notifications only appear after the scheduler activates the download. +fn runtime_event_bridge_emits_start_only_when_scheduler_activates_download() { + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + sessions + .lock() + .expect("sessions lock should succeed") + .connect("sess-a"); + + let mut engine = DownloadEngine::new(); + engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions))); + let gid = engine.add_uri("https://example.org/file.iso").gid(); + + assert!( + sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .is_none(), + "registration alone should not emit a start notification" + ); + + let _ = engine.schedule_once(); + + let frame = sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .expect("scheduler activation should emit start notification"); + match frame { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadStart")); + assert!(text.contains(&format!(r#""gid":"{gid}""#))); + } + other => panic!("unexpected frame: {other:?}"), + } +} + +#[test] +/// Verifies that resumed downloads emit start only after they become active again. +fn runtime_event_bridge_does_not_emit_start_until_resumed_download_is_active_again() { + let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); + sessions + .lock() + .expect("sessions lock should succeed") + .connect("sess-a"); + + let mut engine = DownloadEngine::new(); + engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions))); + let gid = engine.add_uri("https://example.org/file.iso").gid(); + let _ = engine.schedule_once(); + + let _ = sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a"); + + engine.pause(gid).expect("pause should succeed"); + let pause = sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .expect("pause should emit pause notification"); + match pause { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadPause")); + } + other => panic!("unexpected pause frame: {other:?}"), + } + + engine + .resume(gid) + .expect("resume should move download back to waiting"); + assert!( + sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .is_none(), + "resume should not emit start notification before reactivation" + ); + + let _ = engine.schedule_once(); + + let frame = sessions + .lock() + .expect("sessions lock should succeed") + .pop_frame("sess-a") + .expect("reactivation should emit start notification"); + match frame { + RpcWebSocketFrame::Text(text) => { + assert!(text.contains("aria2.onDownloadStart")); + assert!(text.contains(&format!(r#""gid":"{gid}""#))); + } + other => panic!("unexpected reactivation frame: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc.rs new file mode 100644 index 0000000..84bcacb --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc.rs @@ -0,0 +1,28 @@ +//! XML-RPC request and response types plus parser and renderer helpers. +#![expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + reason = "the XML-RPC surface keeps transport terminology explicit for compatibility parity" +)] + +/// XML-RPC parser and renderer entrypoints. +mod codec; +/// Conversion helpers between XML-RPC and transport-neutral RPC values. +mod convert; +/// XML-RPC request, response, and value model types. +mod model; +/// Minimal XML token scanner used by the codec parser. +mod scanner; + +#[cfg(test)] +/// XML-RPC parser, renderer, and conversion tests. +mod tests; + +pub use self::codec::{ + xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml, xmlrpc_method_response_from_xml, + xmlrpc_method_response_to_xml, +}; +pub use self::convert::{rpc_value_to_xmlrpc, xmlrpc_value_to_rpc}; +pub use self::model::{ + XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue, +}; diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc/codec.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc/codec.rs new file mode 100644 index 0000000..21ecfd5 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc/codec.rs @@ -0,0 +1,212 @@ +use crate::model::RpcMeta; + +use super::{ + convert::{xmlrpc_fault_code, xmlrpc_fault_string}, + model::XmlRpcFault, +}; +use super::{ + model::{XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue}, + scanner::XmlScanner, +}; + +#[must_use] +/// Renders an XML-RPC method call to a compact XML document. +pub fn xmlrpc_method_call_to_xml(call: &XmlRpcMethodCall) -> String { + let mut out = String::new(); + out.push_str(""); + out.push_str(""); + out.push_str(""); + out.push_str(&escape_xml_text(&call.method_name)); + out.push_str(""); + out.push_str(""); + for param in &call.params { + out.push_str(""); + out.push_str(&xmlrpc_value_to_xml(¶m.value)); + out.push_str(""); + } + out.push_str(""); + out.push_str(""); + out +} + +#[must_use] +/// Renders an XML-RPC method response to a compact XML document. +pub fn xmlrpc_method_response_to_xml(response: &XmlRpcMethodResponse) -> String { + let mut out = String::new(); + out.push_str(""); + out.push_str(""); + if let Some(fault) = &response.fault { + out.push_str(""); + out.push_str(""); + out.push_str("faultCode"); + out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::Int(fault.code))); + out.push_str(""); + out.push_str("faultString"); + out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::String( + fault.message.clone(), + ))); + out.push_str(""); + out.push_str(""); + out.push_str(""); + } else if let Some(value) = &response.value { + out.push_str(""); + out.push_str(&xmlrpc_value_to_xml(value)); + out.push_str(""); + } else { + out.push_str(""); + } + out.push_str(""); + out +} + +/// Parses an XML-RPC method call from raw XML text. +/// +/// # Errors +/// +/// Returns an error when `xml` is not a well-formed XML-RPC method call. +pub fn xmlrpc_method_call_from_xml(xml: &str) -> Result { + let mut p = XmlScanner::new(xml); + p.skip_xml_decl_and_ws(); + p.expect_open("methodCall")?; + let method_name = p.read_text_tag("methodName")?; + let mut params = Vec::new(); + if p.peek_open("params") { + p.expect_open("params")?; + while p.peek_open("param") { + p.expect_open("param")?; + let value = p.read_value_tag()?; + p.expect_close("param")?; + params.push(XmlRpcParam { value }); + } + p.expect_close("params")?; + } else if p.peek_self_closing("params") { + p.expect_self_closing("params")?; + } + p.expect_close("methodCall")?; + p.skip_ws(); + if !p.is_eof() { + return Err("trailing XML after methodCall".to_string()); + } + Ok(XmlRpcMethodCall { + method_name, + params, + meta: RpcMeta::default(), + }) +} + +/// Parses an XML-RPC method response from raw XML text. +/// +/// # Errors +/// +/// Returns an error when `xml` is not a well-formed XML-RPC method response. +pub fn xmlrpc_method_response_from_xml(xml: &str) -> Result { + let mut p = XmlScanner::new(xml); + p.skip_xml_decl_and_ws(); + p.expect_open("methodResponse")?; + let mut value = None; + let mut fault = None; + if p.peek_open("fault") { + p.expect_open("fault")?; + let fault_value = p.read_value_tag()?; + p.expect_close("fault")?; + let XmlRpcValue::Struct(members) = fault_value else { + return Err("fault value must be struct".to_string()); + }; + let code = members + .iter() + .find(|m| m.name == "faultCode") + .and_then(|m| xmlrpc_fault_code(&m.value)) + .ok_or_else(|| "missing faultCode".to_string())?; + let message = members + .iter() + .find(|m| m.name == "faultString") + .and_then(|m| xmlrpc_fault_string(&m.value)) + .ok_or_else(|| "missing faultString".to_string())?; + fault = Some(XmlRpcFault { + code, + message, + error: None, + }); + } else if p.peek_open("params") { + p.expect_open("params")?; + if p.peek_open("param") { + p.expect_open("param")?; + value = Some(p.read_value_tag()?); + p.expect_close("param")?; + } + p.expect_close("params")?; + } else if p.peek_self_closing("params") { + p.expect_self_closing("params")?; + } + p.expect_close("methodResponse")?; + p.skip_ws(); + if !p.is_eof() { + return Err("trailing XML after methodResponse".to_string()); + } + Ok(XmlRpcMethodResponse { + value, + fault, + meta: RpcMeta::default(), + }) +} + +/// Renders a single XML-RPC value into a compact `...` fragment. +fn xmlrpc_value_to_xml(value: &XmlRpcValue) -> String { + match value { + XmlRpcValue::Nil => "".to_string(), + XmlRpcValue::Bool(v) => format!("{}", i32::from(*v)), + XmlRpcValue::Int(v) => format!("{v}"), + XmlRpcValue::String(v) => format!("{}", escape_xml_text(v)), + XmlRpcValue::Double(v) => format!("{v}"), + XmlRpcValue::DateTime(v) => { + format!( + "{}", + escape_xml_text(v) + ) + } + XmlRpcValue::Base64(v) => format!("{}", base64_encode(v)), + XmlRpcValue::Array(values) => { + let mut out = String::from(""); + for item in values { + out.push_str(&xmlrpc_value_to_xml(item)); + } + out.push_str(""); + out + } + XmlRpcValue::Struct(members) => { + let mut out = String::from(""); + for member in members { + out.push_str(""); + out.push_str(&escape_xml_text(&member.name)); + out.push_str(""); + out.push_str(&xmlrpc_value_to_xml(&member.value)); + out.push_str(""); + } + out.push_str(""); + out + } + } +} + +/// Escapes text content for safe inclusion inside XML element bodies. +pub(super) fn escape_xml_text(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// Encodes raw bytes using the XML-RPC base64 scalar format. +pub(super) fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine; + + base64::engine::general_purpose::STANDARD.encode(bytes) +} diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc/convert.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc/convert.rs new file mode 100644 index 0000000..ebc50f5 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc/convert.rs @@ -0,0 +1,77 @@ +use std::collections::BTreeMap; + +use crate::model::RpcValue; + +use super::{ + codec::base64_encode, + model::{XmlRpcMember, XmlRpcValue}, +}; + +#[must_use] +/// Converts a transport-neutral RPC value into its XML-RPC representation. +pub fn rpc_value_to_xmlrpc(value: RpcValue) -> XmlRpcValue { + match value { + RpcValue::Null => XmlRpcValue::Nil, + RpcValue::Bool(v) => XmlRpcValue::Bool(v), + RpcValue::Number(v) => { + i32::try_from(v).map_or_else(|_| XmlRpcValue::String(v.to_string()), XmlRpcValue::Int) + } + RpcValue::String(v) => XmlRpcValue::String(v), + RpcValue::Array(values) => { + XmlRpcValue::Array(values.into_iter().map(rpc_value_to_xmlrpc).collect()) + } + RpcValue::Object(map) => XmlRpcValue::Struct( + map.into_iter() + .map(|(name, value)| XmlRpcMember { + name, + value: rpc_value_to_xmlrpc(value), + }) + .collect(), + ), + } +} + +#[must_use] +/// Converts an XML-RPC value into the transport-neutral RPC representation. +pub fn xmlrpc_value_to_rpc(value: XmlRpcValue) -> RpcValue { + match value { + XmlRpcValue::Nil => RpcValue::Null, + XmlRpcValue::Bool(v) => RpcValue::Bool(v), + XmlRpcValue::Int(v) => RpcValue::Number(i64::from(v)), + XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => RpcValue::String(v), + XmlRpcValue::Double(v) => RpcValue::String(v.to_string()), + XmlRpcValue::Base64(v) => RpcValue::String(base64_encode(&v)), + XmlRpcValue::Array(values) => { + RpcValue::Array(values.into_iter().map(xmlrpc_value_to_rpc).collect()) + } + XmlRpcValue::Struct(members) => { + let mut map = BTreeMap::new(); + for member in members { + map.insert(member.name, xmlrpc_value_to_rpc(member.value)); + } + RpcValue::Object(map) + } + } +} + +/// Extracts an XML-RPC fault code from the normalized fault struct member value. +pub(super) fn xmlrpc_fault_code(value: &XmlRpcValue) -> Option { + match value { + XmlRpcValue::Int(v) => Some(*v), + XmlRpcValue::String(v) => v.trim().parse::().ok(), + _ => None, + } +} + +/// Extracts an XML-RPC fault string from the normalized fault struct member value. +pub(super) fn xmlrpc_fault_string(value: &XmlRpcValue) -> Option { + match value { + XmlRpcValue::Nil => Some(String::new()), + XmlRpcValue::Bool(v) => Some(if *v { "1" } else { "0" }.to_string()), + XmlRpcValue::Int(v) => Some(v.to_string()), + XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => Some(v.clone()), + XmlRpcValue::Double(v) => Some(v.to_string()), + XmlRpcValue::Base64(v) => Some(base64_encode(v)), + XmlRpcValue::Array(_) | XmlRpcValue::Struct(_) => None, + } +} diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc/model.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc/model.rs new file mode 100644 index 0000000..de472a5 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc/model.rs @@ -0,0 +1,85 @@ +use crate::model::{RpcError, RpcMeta}; + +#[derive(Debug, Clone, PartialEq)] +/// XML-RPC value representation used by the compatibility layer. +pub enum XmlRpcValue { + /// 32-bit signed integer. + Int(i32), + /// Boolean scalar. + Bool(bool), + /// UTF-8 string scalar. + String(String), + /// Floating-point scalar. + Double(f64), + /// ISO 8601 timestamp string. + DateTime(String), + /// Base64-encoded binary payload. + Base64(Vec), + /// Ordered list of nested values. + Array(Vec), + /// Structured list of named members. + Struct(Vec), + /// Nil extension value. + Nil, +} + +#[derive(Debug, Clone, PartialEq)] +/// Named member within an XML-RPC struct value. +pub struct XmlRpcMember { + /// Member name. + pub name: String, + /// Member value. + pub value: XmlRpcValue, +} + +#[derive(Debug, Clone, PartialEq)] +/// Positional XML-RPC parameter wrapper. +pub struct XmlRpcParam { + /// Parameter value. + pub value: XmlRpcValue, +} + +#[derive(Debug, Clone, PartialEq)] +/// XML-RPC method call payload. +pub struct XmlRpcMethodCall { + /// Requested method name. + pub method_name: String, + /// Positional parameters. + pub params: Vec, + /// Supplemental request metadata. + pub meta: RpcMeta, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// XML-RPC fault payload. +pub struct XmlRpcFault { + /// Fault code returned on the wire. + pub code: i32, + /// Human-readable fault message. + pub message: String, + /// Normalized backing error, if one exists. + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +/// XML-RPC method response payload. +pub struct XmlRpcMethodResponse { + /// Successful result value, if any. + pub value: Option, + /// Fault payload, if the request failed. + pub fault: Option, + /// Supplemental response metadata. + pub meta: RpcMeta, +} + +impl XmlRpcMethodResponse { + #[must_use] + /// Builds a successful XML-RPC response containing the provided value. + pub fn success(value: XmlRpcValue) -> Self { + Self { + value: Some(value), + fault: None, + meta: RpcMeta::default(), + } + } +} diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc/scanner.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc/scanner.rs new file mode 100644 index 0000000..885fdf8 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc/scanner.rs @@ -0,0 +1,435 @@ +use super::model::{XmlRpcMember, XmlRpcValue}; + +/// Small stateful scanner for the permissive XML-RPC parser. +pub(super) struct XmlScanner<'a> { + /// Entire XML document being parsed. + input: &'a str, + /// Current byte position within `input`. + pos: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +/// Kind of XML opening tag encountered by the scanner. +enum TagKind { + /// Standard opening tag with a matching close tag later in the stream. + Open, + /// Self-closing tag with no body content. + SelfClosing, +} + +impl<'a> XmlScanner<'a> { + /// Creates a scanner positioned at the beginning of the input document. + pub(super) const fn new(input: &'a str) -> Self { + Self { input, pos: 0 } + } + + /// Returns whether the scanner has consumed the entire input string. + pub(super) const fn is_eof(&self) -> bool { + self.pos >= self.input.len() + } + + /// Returns the remaining unparsed input slice. + fn rest(&self) -> &'a str { + &self.input[self.pos..] + } + + /// Skips XML whitespace plus comments and processing instructions. + pub(super) fn skip_ws(&mut self) { + loop { + let start = self.pos; + while let Some(ch) = self.rest().chars().next() { + if ch.is_whitespace() { + self.pos += ch.len_utf8(); + } else { + break; + } + } + if self.rest().starts_with("") { + self.pos += end + 3; + continue; + } + self.pos = self.input.len(); + } else if self.rest().starts_with("") { + self.pos += end + 2; + continue; + } + self.pos = self.input.len(); + } + if self.pos == start { + break; + } + } + } + + /// Skips the optional XML declaration and any surrounding whitespace. + pub(super) fn skip_xml_decl_and_ws(&mut self) { + self.skip_ws(); + } + + /// Returns whether the next non-whitespace token is an opening tag for `tag`. + pub(super) fn peek_open(&mut self, tag: &str) -> bool { + self.skip_ws(); + matches!(self.match_open_tag(tag), Some(TagKind::Open)) + } + + /// Returns whether the next non-whitespace token is a self-closing tag for `tag`. + pub(super) fn peek_self_closing(&mut self, tag: &str) -> bool { + self.skip_ws(); + matches!(self.match_open_tag(tag), Some(TagKind::SelfClosing)) + } + + /// Consumes an opening tag and errors when another token appears instead. + pub(super) fn expect_open(&mut self, tag: &str) -> Result<(), String> { + self.skip_ws(); + match self.consume_open_tag(tag) { + Some(TagKind::Open) => Ok(()), + _ => Err(format!("expected opening tag <{tag}>")), + } + } + + /// Consumes a closing tag and errors when it is absent. + pub(super) fn expect_close(&mut self, tag: &str) -> Result<(), String> { + self.skip_ws(); + if let Some(len) = self.match_close_tag(tag) { + self.pos += len; + Ok(()) + } else { + Err(format!("expected closing tag ")) + } + } + + /// Consumes a self-closing tag and errors when it is absent. + pub(super) fn expect_self_closing(&mut self, tag: &str) -> Result<(), String> { + self.skip_ws(); + match self.consume_open_tag(tag) { + Some(TagKind::SelfClosing) => Ok(()), + _ => Err(format!("expected self-closing tag <{tag}/>")), + } + } + + /// Reads a text-only tag body, accepting an empty self-closing form. + pub(super) fn read_text_tag(&mut self, tag: &str) -> Result { + if self.peek_self_closing(tag) { + self.expect_self_closing(tag)?; + return Ok(String::new()); + } + self.expect_open(tag)?; + let text = self.read_text_until_close(tag)?; + self.expect_close(tag)?; + Ok(text) + } + + /// Reads a text-only tag body and trims surrounding whitespace. + fn read_trimmed_text_tag(&mut self, tag: &str) -> Result { + Ok(self.read_text_tag(tag)?.trim().to_string()) + } + + /// Reads decoded text until the matching closing tag is encountered. + fn read_text_until_close(&mut self, tag: &str) -> Result { + let mut out = String::new(); + loop { + if self.match_close_tag(tag).is_some() { + return Ok(out); + } + let rest = self.rest(); + if rest.is_empty() { + return Err(format!("missing closing tag ")); + } + if rest.starts_with("") + .ok_or_else(|| "unterminated CDATA section".to_string())?; + out.push_str(&rest[9..end]); + self.pos += end + 3; + continue; + } + if rest.starts_with("") + .ok_or_else(|| "unterminated XML comment".to_string())?; + self.pos += end + 3; + continue; + } + if rest.starts_with("") + .ok_or_else(|| "unterminated XML processing instruction".to_string())?; + self.pos += end + 2; + continue; + } + if rest.starts_with('<') { + return Err(format!("unexpected nested tag inside <{tag}> text")); + } + let next = rest.find('<').unwrap_or(rest.len()); + out.push_str(&unescape_xml_text(&rest[..next])?); + self.pos += next; + } + } + + /// Returns whether the next non-whitespace token is either opening form for `tag`. + fn next_is_value_tag(&mut self, tag: &str) -> bool { + self.peek_open(tag) || self.peek_self_closing(tag) + } + + /// Reads a trimmed integer tag body into an i32 value. + fn read_i32_tag(&mut self, tag: &str) -> Result { + self.read_trimmed_text_tag(tag)? + .parse::() + .map_err(|error| error.to_string()) + } + + /// Reads a large integer tag, preserving out-of-range values as strings. + fn read_lossy_i32_or_string_tag(&mut self, tag: &str) -> Result { + let raw = self.read_trimmed_text_tag(tag)?; + raw.parse::() + .map(|value| { + i32::try_from(value).map_or_else(|_| XmlRpcValue::String(raw), XmlRpcValue::Int) + }) + .map_err(|error| error.to_string()) + } + + /// Reads a complete XML-RPC `` element into the normalized value tree. + pub(super) fn read_value_tag(&mut self) -> Result { + if self.peek_self_closing("value") { + self.expect_self_closing("value")?; + return Ok(XmlRpcValue::String(String::new())); + } + self.expect_open("value")?; + self.skip_ws(); + let value = if self.next_is_value_tag("int") { + XmlRpcValue::Int(self.read_i32_tag("int")?) + } else if self.next_is_value_tag("i4") { + XmlRpcValue::Int(self.read_i32_tag("i4")?) + } else if self.next_is_value_tag("i8") { + self.read_lossy_i32_or_string_tag("i8")? + } else if self.next_is_value_tag("biginteger") { + self.read_lossy_i32_or_string_tag("biginteger")? + } else if self.next_is_value_tag("boolean") { + let raw = self.read_trimmed_text_tag("boolean")?; + match raw.as_str() { + "1" | "true" => XmlRpcValue::Bool(true), + "0" | "false" => XmlRpcValue::Bool(false), + _ => return Err("invalid boolean value".to_string()), + } + } else if self.match_open_tag("string").is_some() { + XmlRpcValue::String(self.read_text_tag("string")?) + } else if self.next_is_value_tag("double") { + XmlRpcValue::Double( + self.read_trimmed_text_tag("double")? + .parse::() + .map_err(|error| error.to_string())?, + ) + } else if self.next_is_value_tag("dateTime.iso8601") { + XmlRpcValue::DateTime(self.read_trimmed_text_tag("dateTime.iso8601")?) + } else if self.next_is_value_tag("base64") { + let data = self.read_trimmed_text_tag("base64")?; + XmlRpcValue::Base64(base64_decode(&data)?) + } else if self.peek_open("array") { + self.expect_open("array")?; + let mut items = Vec::new(); + if self.peek_self_closing("data") { + self.expect_self_closing("data")?; + } else { + self.expect_open("data")?; + while self.peek_open("value") || self.peek_self_closing("value") { + items.push(self.read_value_tag()?); + } + self.expect_close("data")?; + } + self.expect_close("array")?; + XmlRpcValue::Array(items) + } else if self.peek_self_closing("array") { + self.expect_self_closing("array")?; + XmlRpcValue::Array(Vec::new()) + } else if self.peek_open("struct") { + self.expect_open("struct")?; + let mut members = Vec::new(); + while self.peek_open("member") { + self.expect_open("member")?; + let name = self.read_text_tag("name")?; + let value = self.read_value_tag()?; + self.expect_close("member")?; + members.push(XmlRpcMember { name, value }); + } + self.expect_close("struct")?; + XmlRpcValue::Struct(members) + } else if self.peek_self_closing("struct") { + self.expect_self_closing("struct")?; + XmlRpcValue::Struct(Vec::new()) + } else if self.peek_self_closing("nil") { + self.expect_self_closing("nil")?; + XmlRpcValue::Nil + } else if self.peek_open("nil") { + self.expect_open("nil")?; + self.expect_close("nil")?; + XmlRpcValue::Nil + } else if self.rest().starts_with('<') { + return Err("unsupported XML-RPC value type".to_string()); + } else { + XmlRpcValue::String(self.read_text_until_close("value")?) + }; + self.expect_close("value")?; + Ok(value) + } + + /// Peeks at the next opening tag kind without consuming it. + fn match_open_tag(&self, tag: &str) -> Option { + let rest = self.rest(); + scan_open_tag(rest, tag).map(|(kind, _)| kind) + } + + /// Consumes the next opening tag for `tag` and returns its concrete kind. + fn consume_open_tag(&mut self, tag: &str) -> Option { + let (kind, len) = scan_open_tag(self.rest(), tag)?; + self.pos += len; + Some(kind) + } + + /// Returns the byte length of the next matching closing tag, if present. + fn match_close_tag(&self, tag: &str) -> Option { + scan_close_tag(self.rest(), tag) + } +} + +/// Scans a namespaced-or-plain opening tag and returns its kind plus byte length. +fn scan_open_tag(rest: &str, tag: &str) -> Option<(TagKind, usize)> { + if !rest.starts_with('<') + || rest.starts_with("' { + break; + } + idx += ch.len_utf8(); + } + let name = &rest[1..idx]; + if name.rsplit(':').next()? != tag { + return None; + } + + let mut cursor = idx; + let mut quote = None; + while let Some(ch) = rest[cursor..].chars().next() { + let ch_len = ch.len_utf8(); + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } + cursor += ch_len; + continue; + } + match ch { + '"' | '\'' => { + quote = Some(ch); + cursor += ch_len; + } + '>' => return Some((TagKind::Open, cursor + ch_len)), + '/' if rest[cursor..].starts_with("/>") => { + return Some((TagKind::SelfClosing, cursor + 2)); + } + _ => cursor += ch_len, + } + } + None +} + +/// Scans a namespaced-or-plain closing tag and returns its byte length. +fn scan_close_tag(rest: &str, tag: &str) -> Option { + if !rest.starts_with("' { + break; + } + idx += ch.len_utf8(); + } + let name = &rest[2..idx]; + if name.rsplit(':').next()? != tag { + return None; + } + while let Some(ch) = rest[idx..].chars().next() { + if ch.is_whitespace() { + idx += ch.len_utf8(); + } else { + break; + } + } + rest[idx..].starts_with('>').then_some(idx + 1) +} + +/// Decodes XML entities in a text node into their Unicode scalar values. +fn unescape_xml_text(input: &str) -> Result { + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' { + let rem = &input[i..]; + if rem.starts_with("&") { + out.push('&'); + i += 5; + } else if rem.starts_with("<") { + out.push('<'); + i += 4; + } else if rem.starts_with(">") { + out.push('>'); + i += 4; + } else if rem.starts_with(""") { + out.push('"'); + i += 6; + } else if rem.starts_with("'") { + out.push('\''); + i += 6; + } else if rem.starts_with("&#") { + let (digits_start, radix) = if rem.starts_with("&#x") || rem.starts_with("&#X") { + (3, 16) + } else { + (2, 10) + }; + let end = rem + .find(';') + .ok_or_else(|| "invalid numeric XML entity".to_string())?; + let digits = &rem[digits_start..end]; + if digits.is_empty() { + return Err("invalid numeric XML entity".to_string()); + } + let codepoint = u32::from_str_radix(digits, radix) + .map_err(|_| "invalid numeric XML entity".to_string())?; + let ch = char::from_u32(codepoint) + .ok_or_else(|| "invalid numeric XML entity".to_string())?; + out.push(ch); + i += end + 1; + } else { + return Err("unsupported XML entity".to_string()); + } + } else if let Some(ch) = input[i..].chars().next() { + out.push(ch); + i += ch.len_utf8(); + } else { + break; + } + } + Ok(out) +} + +/// Decodes a base64 value while tolerating insignificant XML whitespace. +fn base64_decode(input: &str) -> Result, String> { + use base64::Engine; + + let compact = input + .bytes() + .filter(|byte| !byte.is_ascii_whitespace()) + .collect::>(); + base64::engine::general_purpose::STANDARD + .decode(compact) + .map_err(|error| error.to_string()) +} diff --git a/crates/aria2-rust-pro-rpc/src/xmlrpc/tests.rs b/crates/aria2-rust-pro-rpc/src/xmlrpc/tests.rs new file mode 100644 index 0000000..1a003e2 --- /dev/null +++ b/crates/aria2-rust-pro-rpc/src/xmlrpc/tests.rs @@ -0,0 +1,685 @@ +use std::collections::BTreeMap; + +use super::*; +use crate::model::{RpcMeta, RpcValue}; + +#[test] +fn parses_method_call_with_common_value_types() { + let xml = r#" + + system.multicall + + 7 + 1 + aria2 + 3.5 + 20260526T10:11:12 + YXI= + x + a-2 + +"#; + + let call = xmlrpc_method_call_from_xml(xml).expect("parse methodCall"); + assert_eq!(call.method_name, "system.multicall"); + assert_eq!( + call.params, + vec![ + XmlRpcParam { + value: XmlRpcValue::Int(7), + }, + XmlRpcParam { + value: XmlRpcValue::Bool(true), + }, + XmlRpcParam { + value: XmlRpcValue::String("aria2".to_string()), + }, + XmlRpcParam { + value: XmlRpcValue::Double(3.5), + }, + XmlRpcParam { + value: XmlRpcValue::DateTime("20260526T10:11:12".to_string()), + }, + XmlRpcParam { + value: XmlRpcValue::Base64(b"ar".to_vec()), + }, + XmlRpcParam { + value: XmlRpcValue::Array(vec![ + XmlRpcValue::Nil, + XmlRpcValue::String("x".to_string()), + ]), + }, + XmlRpcParam { + value: XmlRpcValue::Struct(vec![XmlRpcMember { + name: "a".to_string(), + value: XmlRpcValue::Int(-2), + }]), + }, + ] + ); +} + +#[test] +fn parses_method_response_success_and_fault() { + let ok_xml = + xmlrpc_method_response_to_xml(&XmlRpcMethodResponse::success(XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "status".to_string(), + value: XmlRpcValue::String("done".to_string()), + }, + ]))); + let ok = xmlrpc_method_response_from_xml(&ok_xml).expect("parse success response"); + assert_eq!( + ok, + XmlRpcMethodResponse { + value: Some(XmlRpcValue::Struct(vec![XmlRpcMember { + name: "status".to_string(), + value: XmlRpcValue::String("done".to_string()), + }])), + fault: None, + meta: RpcMeta::default(), + } + ); + + let fault_xml = r#" + + + + + faultCode4 + faultStringbad arg + + + +"#; + let fault = xmlrpc_method_response_from_xml(fault_xml).expect("parse fault response"); + assert_eq!( + fault, + XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: 4, + message: "bad arg".to_string(), + error: None, + }), + meta: RpcMeta::default(), + } + ); +} + +#[test] +fn roundtrips_rendered_method_call_and_response() { + let call = XmlRpcMethodCall { + method_name: "aria2.addUri".to_string(), + params: vec![ + XmlRpcParam { + value: XmlRpcValue::Nil, + }, + XmlRpcParam { + value: XmlRpcValue::Array(vec![XmlRpcValue::Bool(false)]), + }, + ], + meta: RpcMeta::default(), + }; + let parsed_call = xmlrpc_method_call_from_xml(&xmlrpc_method_call_to_xml(&call)) + .expect("roundtrip methodCall"); + assert_eq!(parsed_call, call); + + let response = XmlRpcMethodResponse::success(XmlRpcValue::Array(vec![ + XmlRpcValue::Int(1), + XmlRpcValue::String("ok".to_string()), + ])); + let parsed_response = + xmlrpc_method_response_from_xml(&xmlrpc_method_response_to_xml(&response)) + .expect("roundtrip methodResponse"); + assert_eq!(parsed_response, response); +} + +#[test] +fn converts_nested_rpc_to_xmlrpc() { + let mut inner = BTreeMap::new(); + inner.insert("flag".to_string(), RpcValue::Bool(true)); + inner.insert( + "list".to_string(), + RpcValue::Array(vec![RpcValue::Number(7), RpcValue::Null]), + ); + + let mut root = BTreeMap::new(); + root.insert("name".to_string(), RpcValue::String("aria".to_string())); + root.insert("meta".to_string(), RpcValue::Object(inner)); + + let xml = rpc_value_to_xmlrpc(RpcValue::Object(root)); + assert_eq!( + xml, + XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "meta".to_string(), + value: XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "flag".to_string(), + value: XmlRpcValue::Bool(true), + }, + XmlRpcMember { + name: "list".to_string(), + value: XmlRpcValue::Array(vec![XmlRpcValue::Int(7), XmlRpcValue::Nil]), + }, + ]), + }, + XmlRpcMember { + name: "name".to_string(), + value: XmlRpcValue::String("aria".to_string()), + }, + ]) + ); +} + +#[test] +fn converts_nested_xmlrpc_back_to_rpc() { + let value = XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "name".to_string(), + value: XmlRpcValue::String("aria2".to_string()), + }, + XmlRpcMember { + name: "items".to_string(), + value: XmlRpcValue::Array(vec![ + XmlRpcValue::Int(42), + XmlRpcValue::Bool(false), + XmlRpcValue::Nil, + ]), + }, + XmlRpcMember { + name: "stamp".to_string(), + value: XmlRpcValue::DateTime("2026-01-01T00:00:00Z".to_string()), + }, + ]); + + let rpc = xmlrpc_value_to_rpc(value); + let expected = RpcValue::Object(BTreeMap::from([ + ( + "items".to_string(), + RpcValue::Array(vec![ + RpcValue::Number(42), + RpcValue::Bool(false), + RpcValue::Null, + ]), + ), + ("name".to_string(), RpcValue::String("aria2".to_string())), + ( + "stamp".to_string(), + RpcValue::String("2026-01-01T00:00:00Z".to_string()), + ), + ])); + assert_eq!(rpc, expected); +} + +#[test] +fn renders_method_call_xml_with_escaped_text() { + let call = XmlRpcMethodCall { + method_name: "aria2.addUri".to_string(), + params: vec![XmlRpcParam { + value: XmlRpcValue::Array(vec![XmlRpcValue::String( + "https://a.example/?q=1&v=".to_string(), + )]), + }], + meta: RpcMeta::default(), + }; + + let xml = xmlrpc_method_call_to_xml(&call); + assert!(xml.contains("aria2.addUri")); + assert!(xml.contains("&")); + assert!(xml.contains("<ok>")); +} + +#[test] +fn renders_fault_method_response_xml() { + let resp = XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: 3, + message: "bad & state".to_string(), + error: None, + }), + meta: RpcMeta::default(), + }; + + let xml = xmlrpc_method_response_to_xml(&resp); + assert!(xml.contains("")); + assert!(xml.contains("faultCode")); + assert!(xml.contains("3")); + assert!(xml.contains("bad <arg> & state")); +} + +#[test] +fn parses_aria2_style_multicall_raw_payload() { + let xml = r#" + + system.multicall + + + + + + + + methodNamearia2.tellActive + params + + + + + methodNamearia2.tellWaiting + params010 + + + + + + + +"#; + + let call = xmlrpc_method_call_from_xml(xml).expect("parse aria2 multicall"); + assert_eq!(call.method_name, "system.multicall"); + assert_eq!(call.params.len(), 1); +} + +#[test] +fn parses_empty_params_self_closing_for_call_and_response() { + let call_xml = r#" + + aria2.tellActive + +"#; + let call = xmlrpc_method_call_from_xml(call_xml).expect("parse methodCall with "); + assert_eq!(call.method_name, "aria2.tellActive"); + assert!(call.params.is_empty()); + + let resp_xml = r#" + + +"#; + let resp = + xmlrpc_method_response_from_xml(resp_xml).expect("parse methodResponse with "); + assert!(resp.value.is_none()); + assert!(resp.fault.is_none()); +} + +#[test] +fn parses_comments_processing_instructions_and_spaced_self_closing_tags() { + let call_xml = r#" + + + + system.listNotifications + + +"#; + let call = xmlrpc_method_call_from_xml(call_xml).expect("parse decorated methodCall"); + assert_eq!(call.method_name, "system.listNotifications"); + assert!(call.params.is_empty()); + + let response_xml = r#" + + + + +"#; + let response = + xmlrpc_method_response_from_xml(response_xml).expect("parse decorated methodResponse"); + assert!(response.value.is_none()); + assert!(response.fault.is_none()); +} + +#[test] +fn parses_empty_typed_values_and_collections() { + let xml = r#" + + aria2.addUri + + + + + + + +"#; + + let call = xmlrpc_method_call_from_xml(xml).expect("parse empty typed values"); + assert_eq!( + call.params, + vec![ + XmlRpcParam { + value: XmlRpcValue::String(String::new()), + }, + XmlRpcParam { + value: XmlRpcValue::String(String::new()), + }, + XmlRpcParam { + value: XmlRpcValue::Array(Vec::new()), + }, + XmlRpcParam { + value: XmlRpcValue::Struct(Vec::new()), + }, + XmlRpcParam { + value: XmlRpcValue::Nil, + }, + ] + ); +} + +#[test] +fn unescapes_numeric_xml_entities_in_strings_and_member_names() { + let xml = r#" + + + + + + + line_break + alpha beta! + + + + + +"#; + + let response = xmlrpc_method_response_from_xml(xml).expect("parse numeric XML entities"); + assert_eq!( + response.value, + Some(XmlRpcValue::Struct(vec![XmlRpcMember { + name: "line_break".to_string(), + value: XmlRpcValue::String("alpha\nbeta!".to_string()), + }])) + ); +} + +#[test] +fn converts_large_rpc_numbers_to_lossless_xmlrpc_strings() { + let value = rpc_value_to_xmlrpc(RpcValue::Number(i64::from(i32::MAX) + 1)); + assert_eq!(value, XmlRpcValue::String("2147483648".to_string())); +} + +#[test] +fn parses_fault_with_i4_code_and_escaped_message() { + let xml = r#" + + + + + faultCode1 + faultStringMethod not found: <aria2.nope> + + + +"#; + + let resp = xmlrpc_method_response_from_xml(xml).expect("parse aria2-ish fault"); + let fault = resp.fault.expect("fault expected"); + assert_eq!(fault.code, 1); + assert_eq!(fault.message, "Method not found: "); +} + +#[test] +fn parses_untyped_value_text_as_string() { + let xml = r#" + + + OK + +"#; + let resp = xmlrpc_method_response_from_xml(xml).expect("parse untyped text value"); + assert_eq!(resp.value, Some(XmlRpcValue::String("OK".to_string()))); +} + +#[test] +fn parses_attribute_decorated_and_prefixed_value_tags() { + let xml = r#" + + aria2.tellStatus + + + + gid-1 + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + + let call = xmlrpc_method_call_from_xml(xml).expect("parse decorated XML-RPC call"); + assert_eq!(call.method_name, "aria2.tellStatus"); + assert_eq!( + call.params, + vec![ + XmlRpcParam { + value: XmlRpcValue::String("gid-1".to_string()), + }, + XmlRpcParam { + value: XmlRpcValue::Nil, + }, + XmlRpcParam { + value: XmlRpcValue::Base64(Vec::new()), + }, + XmlRpcParam { + value: XmlRpcValue::Array(Vec::new()), + }, + XmlRpcParam { + value: XmlRpcValue::Struct(Vec::new()), + }, + ] + ); +} + +#[test] +fn parses_fault_with_i8_code_and_untyped_fault_string() { + let xml = r#" + + + + + faultCode1 + faultStringMethod not found: aria2.noSuchMethod + + + +"#; + + let resp = xmlrpc_method_response_from_xml(xml).expect("parse fault with i8 code"); + assert_eq!( + resp.fault, + Some(XmlRpcFault { + code: 1, + message: "Method not found: aria2.noSuchMethod".to_string(), + error: None, + }) + ); +} + +#[test] +fn parses_large_i8_losslessly_as_string_value() { + let xml = r#" + + + 2147483648 + +"#; + + let resp = xmlrpc_method_response_from_xml(xml).expect("parse large i8 value"); + assert_eq!( + resp.value, + Some(XmlRpcValue::String("2147483648".to_string())) + ); +} + +#[test] +fn parses_cdata_and_mixed_text_inside_string_and_method_name() { + let xml = r#" + + + + + + ]]> beta + + + +"#; + + let call = xmlrpc_method_call_from_xml(xml).expect("parse CDATA-rich method call"); + assert_eq!(call.method_name, "system.listMethods"); + assert_eq!( + call.params, + vec![XmlRpcParam { + value: XmlRpcValue::String("alpha<&>\nbeta".to_string()), + }] + ); +} + +#[test] +fn parses_whitespace_padded_scalar_tags_losslessly() { + let xml = r#" + + + + + + + + 7 + + -2 + + 2147483647 + + + 2147483648 + + + 3.5 + + + 20260527T12:34:56 + + + + + + +"#; + + let resp = xmlrpc_method_response_from_xml(xml).expect("parse spaced scalar values"); + assert_eq!( + resp.value, + Some(XmlRpcValue::Array(vec![ + XmlRpcValue::Int(7), + XmlRpcValue::Int(-2), + XmlRpcValue::Int(2_147_483_647), + XmlRpcValue::String("2147483648".to_string()), + XmlRpcValue::Double(3.5), + XmlRpcValue::DateTime("20260527T12:34:56".to_string()), + ])) + ); +} + +#[test] +fn parses_fault_members_from_whitespace_padded_scalar_forms() { + let xml = r#" + + + + + faultCode + 12 + + faultString + true + + + + +"#; + + let resp = xmlrpc_method_response_from_xml(xml).expect("parse padded fault fields"); + assert_eq!( + resp.fault, + Some(XmlRpcFault { + code: 12, + message: "1".to_string(), + error: None, + }) + ); +} + +#[test] +fn golden_renders_aria2_add_uri_method_call_shape() { + let call = XmlRpcMethodCall { + method_name: "aria2.addUri".to_string(), + params: vec![ + XmlRpcParam { + value: XmlRpcValue::String("token:abc123".to_string()), + }, + XmlRpcParam { + value: XmlRpcValue::Array(vec![XmlRpcValue::String( + "https://example.org/file.iso".to_string(), + )]), + }, + XmlRpcParam { + value: XmlRpcValue::Struct(vec![ + XmlRpcMember { + name: "split".to_string(), + value: XmlRpcValue::String("16".to_string()), + }, + XmlRpcMember { + name: "max-connection-per-server".to_string(), + value: XmlRpcValue::String("16".to_string()), + }, + ]), + }, + ], + meta: RpcMeta::default(), + }; + + let xml = xmlrpc_method_call_to_xml(&call); + let expected = "aria2.addUritoken:abc123https://example.org/file.isosplit16max-connection-per-server16"; + assert_eq!(xml, expected); +} + +#[test] +fn golden_renders_xmlrpc_fault_struct_shape() { + let response = XmlRpcMethodResponse { + value: None, + fault: Some(XmlRpcFault { + code: 1, + message: "Method not found: aria2.notFound".to_string(), + error: None, + }), + meta: RpcMeta::default(), + }; + let xml = xmlrpc_method_response_to_xml(&response); + let expected = "faultCode1faultStringMethod not found: aria2.notFound"; + assert_eq!(xml, expected); +} diff --git a/crates/aria2-rust-pro-storage/Cargo.toml b/crates/aria2-rust-pro-storage/Cargo.toml new file mode 100644 index 0000000..f241c6e --- /dev/null +++ b/crates/aria2-rust-pro-storage/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "aria2-rust-pro-storage" +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_storage" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/crates/aria2-rust-pro-storage/src/allocation.rs b/crates/aria2-rust-pro-storage/src/allocation.rs new file mode 100644 index 0000000..ec372cc --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/allocation.rs @@ -0,0 +1,47 @@ +use std::path::PathBuf; + +use crate::model::FileLayout; + +/// Declares how files should be materialized before piece writes begin. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AllocationMode { + /// Preserve sparse holes and rely on the filesystem to allocate blocks lazily. + Sparse, + /// Truncate files to their target size without forcing eager block reservation. + Truncate, + /// Request eager allocation for the full file length. + Preallocate, +} + +/// Describes the allocation action for a single file in the layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileAllocation { + /// Absolute or relative target path for the file being allocated. + pub path: PathBuf, + /// Desired final file length in bytes. + pub target_length: u64, + /// Allocation mode to use for this file. + pub mode: AllocationMode, +} + +/// Collects all file-allocation steps for a download layout. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PreallocationPlan { + /// Per-file allocation actions in layout order. + pub files: Vec, +} + +/// Builds a per-file allocation plan for the provided layout. +#[must_use] +pub fn build_preallocation_plan(layout: &FileLayout, mode: AllocationMode) -> PreallocationPlan { + let files = layout + .entries + .iter() + .map(|entry| FileAllocation { + path: entry.path.clone(), + target_length: entry.length, + mode, + }) + .collect(); + PreallocationPlan { files } +} diff --git a/crates/aria2-rust-pro-storage/src/cache.rs b/crates/aria2-rust-pro-storage/src/cache.rs new file mode 100644 index 0000000..08d6e16 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/cache.rs @@ -0,0 +1,59 @@ +use std::collections::BTreeMap; + +use crate::model::PieceIndex; + +/// Configures an optional disk cache. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CacheConfig { + /// Maximum total payload bytes the cache should retain. + pub capacity_bytes: u64, + /// Upper bound for a single cached chunk payload. + pub max_entry_bytes: u64, +} + +/// Holds one cached payload fragment for a piece span. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CacheEntry { + /// Piece that owns the cached payload. + pub piece: PieceIndex, + /// Offset within the piece where the payload begins. + pub chunk_offset: u64, + /// Cached payload bytes. + pub payload: Vec, +} + +/// Maps piece offsets to entries inside the chunk vector. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ChunkCacheIndex { + /// Lookup table keyed by piece index and piece-relative offset. + pub entries: BTreeMap<(PieceIndex, u64), usize>, +} + +/// Simple cache container that keeps payloads and their lookup index together. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DiskCache { + /// Cache configuration, when caching is enabled. + pub config: Option, + /// Stored chunk payloads. + pub chunks: Vec, + /// Reverse lookup for payload positions. + pub index: ChunkCacheIndex, +} + +impl DiskCache { + /// Adds or replaces bookkeeping for a cached chunk payload. + pub fn insert(&mut self, entry: CacheEntry) { + let idx = self.chunks.len(); + self.index + .entries + .insert((entry.piece, entry.chunk_offset), idx); + self.chunks.push(entry); + } + + /// Returns a cached payload entry for the requested piece span. + #[must_use] + pub fn get(&self, piece: PieceIndex, chunk_offset: u64) -> Option<&CacheEntry> { + let idx = self.index.entries.get(&(piece, chunk_offset))?; + self.chunks.get(*idx) + } +} diff --git a/crates/aria2-rust-pro-storage/src/checksum.rs b/crates/aria2-rust-pro-storage/src/checksum.rs new file mode 100644 index 0000000..4235d65 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/checksum.rs @@ -0,0 +1,94 @@ +use crate::model::{Piece, PieceIndex}; + +/// Represents a checksum value together with its algorithm family. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Checksum { + /// SHA-1 checksum encoded as lowercase hexadecimal text. + Sha1(String), + /// SHA-256 checksum encoded as lowercase hexadecimal text. + Sha256(String), + /// MD5 checksum encoded as lowercase hexadecimal text. + Md5(String), + /// Adler-32 checksum encoded as lowercase hexadecimal text. + Adler32(String), + /// CRC-32 checksum encoded as lowercase hexadecimal text. + Crc32(String), +} + +/// Identifies a supported hashing algorithm. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HashAlgorithm { + /// SHA-1. + Sha1, + /// SHA-256. + Sha256, + /// MD5. + Md5, + /// Adler-32. + Adler32, + /// CRC-32. + Crc32, +} + +/// Stores a finalized digest value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HashDigest { + /// Digest algorithm. + pub algorithm: HashAlgorithm, + /// Digest bytes rendered as hexadecimal text. + pub value_hex: String, +} + +/// Reports the result of comparing actual and expected digests. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerificationResult { + /// The calculated digest matched the expected value. + Match, + /// The calculated digest differed from the expected value. + Mismatch { + /// Digest that was expected by the caller. + expected: HashDigest, + /// Digest that was actually calculated from the payload. + actual: HashDigest, + }, + /// The requested algorithm is unsupported by the verifier. + Unsupported(HashAlgorithm), +} + +/// Builds algorithm-specific checksum verifiers. +pub trait HasherFactory { + /// Concrete verifier type produced by the factory. + type Hasher: ChecksumVerifier; + + /// Creates a verifier for the requested algorithm when supported. + fn create(&self, algorithm: HashAlgorithm) -> Option; +} + +/// Incrementally computes a digest for a byte stream. +pub trait ChecksumVerifier { + /// Returns the digest algorithm used by this verifier. + fn algorithm(&self) -> HashAlgorithm; + /// Feeds another payload chunk into the verifier. + fn update(&mut self, chunk: &[u8]); + /// Finalizes and returns the calculated digest. + fn finish(&mut self) -> HashDigest; +} + +/// Verifies piece payloads against expected digests. +pub trait PieceHashVerifier { + /// Verifies the supplied piece payload against an expected digest. + fn verify_piece( + &self, + piece: &Piece, + payload: &[u8], + expected: &HashDigest, + ) -> VerificationResult; + + /// Verifies a piece payload when only the piece index is available. + fn verify_by_index( + &self, + index: PieceIndex, + payload: &[u8], + expected: &HashDigest, + ) -> VerificationResult; +} diff --git a/crates/aria2-rust-pro-storage/src/control.rs b/crates/aria2-rust-pro-storage/src/control.rs new file mode 100644 index 0000000..9c4cc19 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/control.rs @@ -0,0 +1,17 @@ +/// Binary control-file encoding and decoding helpers. +mod binary; +/// Shared control-file data models and error types. +mod model; +#[cfg(test)] +mod tests; +/// Text control-file encoding and decoding helpers. +mod text; + +pub use self::binary::{ + read_aria2_binary_control_file, read_aria2_control_file, write_aria2_control_file, +}; +pub use self::model::{ + ControlFileBinaryModel, ControlFileError, ControlFileTextModel, ControlFileVersion, + ControlMetadata, +}; +pub use self::text::{decode_control_metadata, encode_control_metadata}; diff --git a/crates/aria2-rust-pro-storage/src/control/binary.rs b/crates/aria2-rust-pro-storage/src/control/binary.rs new file mode 100644 index 0000000..2e89df6 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/control/binary.rs @@ -0,0 +1,474 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::model::{DownloadFile, PieceIndex, PieceState}; + +use super::{ + model::{ControlFileError, ControlFileVersion, ControlMetadata}, + text::{decode_control_metadata, encode_control_metadata}, +}; + +/// Magic trailer marker for text metadata appended to binary control files. +const BINARY_CONTROL_TRAILER_MAGIC: &[u8; 8] = b"AR2RTXT1"; +/// Block length used by upstream binary control files for piece bitfields. +const BINARY_PIECE_BLOCK_LENGTH: u64 = 16 * 1024; + +/// Writes the simplified control-file representation used by the Rust implementation. +/// +/// # Errors +/// +/// Returns [`ControlFileError`] when the encoded control file cannot be written. +pub fn write_aria2_control_file( + path: &Path, + metadata: &ControlMetadata, +) -> Result<(), ControlFileError> { + let encoded = encode_binary_control_prefix(metadata) + .and_then(|mut prefix| { + let trailer = encode_binary_control_trailer(metadata)?; + prefix.extend_from_slice(&trailer); + Some(prefix) + }) + .unwrap_or_else(|| encode_control_metadata(metadata).into_bytes()); + fs::write(path, encoded)?; + Ok(()) +} + +/// Reads the simplified control-file representation from disk. +/// +/// # Errors +/// +/// Returns [`ControlFileError`] when the control file cannot be read or decoded. +pub fn read_aria2_control_file(path: &Path) -> Result { + let content = fs::read(path)?; + if looks_like_binary_control_file(&content) { + return decode_binary_control_metadata(path, &content); + } + let text = String::from_utf8(content) + .map_err(|_| ControlFileError::Parse("control file is not valid UTF-8".to_owned()))?; + decode_control_metadata(&text) +} + +/// Reads an upstream-compatible binary `.aria2` control file. +/// +/// # Errors +/// +/// Returns [`ControlFileError`] when the control file cannot be read or decoded. +pub fn read_aria2_binary_control_file(path: &Path) -> Result { + let content = fs::read(path)?; + decode_binary_control_metadata(path, &content) +} + +/// Detects whether the raw bytes look like an upstream binary control file. +fn looks_like_binary_control_file(content: &[u8]) -> bool { + matches!( + content.get(0..2), + Some(bytes) if bytes == [0x00, 0x00] || bytes == [0x00, 0x01] + ) +} + +/// Encodes the upstream-compatible binary control-file prefix when possible. +fn encode_binary_control_prefix(metadata: &ControlMetadata) -> Option> { + let piece_length = metadata.files.first()?.piece_length; + if piece_length == 0 || piece_length > u64::from(u32::MAX) { + return None; + } + let total_length = metadata.files.iter().map(|file| file.length).sum::(); + let piece_count = binary_piece_count(total_length, piece_length)?; + let mut verified_bitfield = vec![0_u8; binary_bitfield_length(piece_count)?]; + + let mut normalized_states = std::collections::BTreeMap::::new(); + for (index, state) in &metadata.piece_states { + normalized_states.insert(index.0, *state); + } + + let mut inflight_pieces = Vec::new(); + for (index, state) in normalized_states { + let span_length = control_piece_span_bytes(index, piece_length, total_length); + if span_length > u64::from(u32::MAX) { + return None; + } + match state { + PieceState::Verified => set_binary_bit(&mut verified_bitfield, index), + PieceState::InFlight => inflight_pieces.push(( + index, + u32::try_from(span_length).ok()?, + vec![0_u8; binary_bitfield_length(binary_piece_block_count(span_length)?)?], + )), + PieceState::Pending | PieceState::Failed => {} + } + } + + let mut bytes = Vec::new(); + push_u16_be(&mut bytes, 1_u16); + push_u32_be(&mut bytes, 0_u32); + push_u32_be(&mut bytes, 0_u32); + push_u32_be(&mut bytes, u32::try_from(piece_length).ok()?); + bytes.extend_from_slice(&total_length.to_be_bytes()); + bytes.extend_from_slice(&0_u64.to_be_bytes()); + push_u32_be(&mut bytes, u32::try_from(verified_bitfield.len()).ok()?); + bytes.extend_from_slice(&verified_bitfield); + push_u32_be(&mut bytes, u32::try_from(inflight_pieces.len()).ok()?); + for (index, length, bitfield) in inflight_pieces { + push_u32_be(&mut bytes, index); + push_u32_be(&mut bytes, length); + push_u32_be(&mut bytes, u32::try_from(bitfield.len()).ok()?); + bytes.extend_from_slice(&bitfield); + } + Some(bytes) +} + +/// Encodes a text metadata trailer that can be appended to a binary control file. +fn encode_binary_control_trailer(metadata: &ControlMetadata) -> Option> { + let text = encode_control_metadata(metadata); + let text_len = u32::try_from(text.len()).ok()?; + let mut bytes = Vec::new(); + bytes.extend_from_slice(BINARY_CONTROL_TRAILER_MAGIC); + push_u32_be(&mut bytes, text_len); + bytes.extend_from_slice(text.as_bytes()); + Some(bytes) +} + +/// Decodes an upstream-compatible binary control file into normalized metadata. +#[expect( + clippy::too_many_lines, + reason = "binary control metadata decoding keeps the upstream v0/v1 format walk in one auditable parser" +)] +fn decode_binary_control_metadata( + path: &Path, + content: &[u8], +) -> Result { + if !looks_like_binary_control_file(content) { + return Err(ControlFileError::UnsupportedBinaryCompatibility); + } + + let mut offset = 0_usize; + let version = match read_binary_slice(content, &mut offset, 2)? { + [0x00, 0x00] => 0_u16, + [0x00, 0x01] => 1_u16, + _ => return Err(ControlFileError::UnsupportedBinaryCompatibility), + }; + + let _extension = read_binary_u32(content, &mut offset, version)?; + let info_hash_length = u32_to_usize(read_binary_u32(content, &mut offset, version)?)?; + if info_hash_length > 20 { + return Err(ControlFileError::Parse( + "invalid binary info hash length".to_owned(), + )); + } + let _info_hash = read_binary_slice(content, &mut offset, info_hash_length)?; + + let piece_length = u64::from(read_binary_u32(content, &mut offset, version)?); + if piece_length == 0 { + return Err(ControlFileError::Parse( + "binary piece length must not be 0".to_owned(), + )); + } + let total_length = read_binary_u64(content, &mut offset, version)?; + let _upload_length = read_binary_u64(content, &mut offset, version)?; + + let piece_count = binary_piece_count(total_length, piece_length).ok_or_else(|| { + ControlFileError::Parse("binary piece count exceeds supported range".to_owned()) + })?; + let bitfield_length = u32_to_usize(read_binary_u32(content, &mut offset, version)?)?; + let expected_bitfield_length = binary_bitfield_length(piece_count).ok_or_else(|| { + ControlFileError::Parse("binary bitfield length exceeds supported range".to_owned()) + })?; + if bitfield_length != expected_bitfield_length { + return Err(ControlFileError::Parse(format!( + "binary bitfield length mismatch: expected {expected_bitfield_length}, got {bitfield_length}" + ))); + } + let verified_bitfield = read_binary_slice(content, &mut offset, bitfield_length)?; + + let mut completed_length = 0_u64; + let mut piece_states = std::collections::BTreeMap::::new(); + for index in 0..piece_count { + if binary_bit_is_set(verified_bitfield, index) { + completed_length = completed_length + .checked_add(control_piece_span_bytes(index, piece_length, total_length)) + .ok_or_else(|| { + ControlFileError::Parse("binary completed length overflowed".to_owned()) + })?; + piece_states.insert(index, PieceState::Verified); + } + } + + let inflight_count = read_binary_u32(content, &mut offset, version)?; + for _ in 0..inflight_count { + let index = read_binary_u32(content, &mut offset, version)?; + if total_length > 0 && index >= piece_count { + return Err(ControlFileError::Parse(format!( + "binary in-flight piece index out of range: {index}" + ))); + } + let piece_span = control_piece_span_bytes(index, piece_length, total_length); + let encoded_length = u64::from(read_binary_u32(content, &mut offset, version)?); + if encoded_length > piece_span { + return Err(ControlFileError::Parse(format!( + "binary in-flight piece length exceeds span: {encoded_length}" + ))); + } + let encoded_bitfield_length = + u32_to_usize(read_binary_u32(content, &mut offset, version)?)?; + let expected_piece_bitfield_length = + binary_bitfield_length(binary_piece_block_count(encoded_length).ok_or_else(|| { + ControlFileError::Parse( + "binary in-flight piece block count exceeds supported range".to_owned(), + ) + })?) + .ok_or_else(|| { + ControlFileError::Parse( + "binary in-flight piece bitfield length exceeds supported range".to_owned(), + ) + })?; + if encoded_bitfield_length != expected_piece_bitfield_length { + return Err(ControlFileError::Parse(format!( + "binary in-flight piece bitfield length mismatch: expected {expected_piece_bitfield_length}, got {encoded_bitfield_length}" + ))); + } + let piece_bitfield = read_binary_slice(content, &mut offset, encoded_bitfield_length)?; + let was_verified = piece_states.get(&index) == Some(&PieceState::Verified); + if !was_verified { + completed_length = completed_length + .checked_add(binary_piece_completed_length( + encoded_length, + piece_bitfield, + )) + .ok_or_else(|| { + ControlFileError::Parse("binary completed length overflowed".to_owned()) + })?; + } + piece_states.insert(index, PieceState::InFlight); + } + + let trailer_slice = content.get(offset..).ok_or_else(|| { + ControlFileError::Parse("binary control trailer offset is out of range".to_owned()) + })?; + if let Some(trailer_metadata) = decode_binary_control_trailer(trailer_slice)? { + return Ok(trailer_metadata); + } + + Ok(ControlMetadata { + version: if version == 0 { + ControlFileVersion::CURRENT + } else { + ControlFileVersion::BINARY_V1 + }, + files: vec![DownloadFile { + path: infer_binary_control_target_path(path), + length: total_length, + piece_length, + }], + checksums: Vec::new(), + piece_states: piece_states + .into_iter() + .map(|(index, state)| (PieceIndex(index), state)) + .collect(), + completed_length, + retry_count: 0, + last_error: None, + last_error_at_unix_ms: None, + last_retry_at_unix_ms: None, + next_retry_at_unix_ms: None, + consecutive_failure_count: None, + active_segment_count: None, + resume_verified_at_unix_ms: None, + resume_generation: None, + }) +} + +/// Decodes the optional text trailer appended to a binary control file. +fn decode_binary_control_trailer( + content: &[u8], +) -> Result, ControlFileError> { + if content.is_empty() { + return Ok(None); + } + if !content.starts_with(BINARY_CONTROL_TRAILER_MAGIC) { + return Ok(None); + } + let minimum_length = checked_add_usize( + BINARY_CONTROL_TRAILER_MAGIC.len(), + 4, + "binary control trailer", + )?; + if content.len() < minimum_length { + return Err(ControlFileError::Parse( + "binary control trailer is truncated".to_owned(), + )); + } + let mut offset = BINARY_CONTROL_TRAILER_MAGIC.len(); + let len_end = checked_add_usize(offset, 4, "binary control trailer length field")?; + let len_bytes = content + .get(offset..len_end) + .ok_or_else(|| ControlFileError::Parse("binary control trailer is truncated".to_owned()))?; + let text_len = u32_to_usize(u32::from_be_bytes(len_bytes.try_into().map_err(|_| { + ControlFileError::Parse("binary control trailer is truncated".to_owned()) + })?))?; + offset = len_end; + let text_end = checked_add_usize(offset, text_len, "binary control trailer payload")?; + if content.len() != text_end { + return Err(ControlFileError::Parse( + "binary control trailer length mismatch".to_owned(), + )); + } + let text_bytes = content + .get(offset..text_end) + .ok_or_else(|| ControlFileError::Parse("binary control trailer is truncated".to_owned()))?; + let text = std::str::from_utf8(text_bytes).map_err(|_| { + ControlFileError::Parse("binary control trailer is not valid UTF-8".to_owned()) + })?; + decode_control_metadata(text).map(Some) +} + +/// Infers the payload target path from a binary `.aria2` file path. +fn infer_binary_control_target_path(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return path.to_path_buf(); + }; + let Some(stem) = file_name.strip_suffix(".aria2") else { + return path.to_path_buf(); + }; + path.with_file_name(stem) +} + +/// Computes the number of pieces required by a binary control file. +fn binary_piece_count(total_length: u64, piece_length: u64) -> Option { + if piece_length == 0 { + return Some(0); + } + u32::try_from(total_length.div_ceil(piece_length)).ok() +} + +/// Computes the number of sub-blocks represented by a piece bitfield. +fn binary_piece_block_count(piece_length: u64) -> Option { + if piece_length == 0 { + return Some(0); + } + u32::try_from(piece_length.div_ceil(BINARY_PIECE_BLOCK_LENGTH)).ok() +} + +/// Computes the byte length needed to store `bit_count` bits. +fn binary_bitfield_length(bit_count: u32) -> Option { + usize::try_from(u64::from(bit_count).div_ceil(8)).ok() +} + +/// Appends a big-endian `u16` to a binary control buffer. +fn push_u16_be(bytes: &mut Vec, value: u16) { + bytes.extend_from_slice(&value.to_be_bytes()); +} + +/// Appends a big-endian `u32` to a binary control buffer. +fn push_u32_be(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_be_bytes()); +} + +/// Marks one bit inside a binary control-file bitfield. +fn set_binary_bit(bitfield: &mut [u8], index: u32) { + let byte_index = usize::try_from(index >> 3).unwrap_or(usize::MAX); + let bit_offset = 7_u32.saturating_sub(index & 7); + if let Some(byte) = bitfield.get_mut(byte_index) { + *byte |= 1_u8 << bit_offset; + } +} + +/// Returns whether one bit is set inside a binary control-file bitfield. +fn binary_bit_is_set(bitfield: &[u8], index: u32) -> bool { + let byte_index = usize::try_from(index >> 3).unwrap_or(usize::MAX); + let bit_offset = 7_u32.saturating_sub(index & 7); + bitfield + .get(byte_index) + .is_some_and(|byte| (byte & (1_u8 << bit_offset)) != 0) +} + +/// Computes the completed bytes represented by one in-flight piece bitfield. +fn binary_piece_completed_length(piece_length: u64, bitfield: &[u8]) -> u64 { + let Some(block_count) = binary_piece_block_count(piece_length) else { + return 0; + }; + let mut completed = 0_u64; + for block_index in 0..block_count { + if binary_bit_is_set(bitfield, block_index) { + let Some(block_start) = u64::from(block_index).checked_mul(BINARY_PIECE_BLOCK_LENGTH) + else { + return piece_length; + }; + let Some(remaining) = piece_length.checked_sub(block_start) else { + return piece_length; + }; + completed = completed.saturating_add(remaining.min(BINARY_PIECE_BLOCK_LENGTH)); + } + } + completed.min(piece_length) +} + +/// Computes the span of one piece within the total binary payload length. +fn control_piece_span_bytes(index: u32, piece_length: u64, total_length: u64) -> u64 { + let Some(start) = u64::from(index).checked_mul(piece_length) else { + return 0; + }; + total_length.saturating_sub(start).min(piece_length) +} + +/// Reads one raw binary slice and advances the offset. +fn read_binary_slice<'a>( + content: &'a [u8], + offset: &mut usize, + len: usize, +) -> Result<&'a [u8], ControlFileError> { + let end = checked_add_usize(*offset, len, "binary control field")?; + let slice = content + .get(*offset..end) + .ok_or_else(|| ControlFileError::Parse("binary control file is truncated".to_owned()))?; + *offset = end; + Ok(slice) +} + +/// Reads a `u32` field using the endianness defined by the binary version. +fn read_binary_u32( + content: &[u8], + offset: &mut usize, + version: u16, +) -> Result { + let raw = read_binary_slice(content, offset, 4)?; + let bytes: [u8; 4] = raw + .try_into() + .map_err(|_| ControlFileError::Parse("binary u32 field is truncated".to_owned()))?; + Ok(if version == 0 { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + }) +} + +/// Reads a `u64` field using the endianness defined by the binary version. +fn read_binary_u64( + content: &[u8], + offset: &mut usize, + version: u16, +) -> Result { + let raw = read_binary_slice(content, offset, 8)?; + let bytes: [u8; 8] = raw + .try_into() + .map_err(|_| ControlFileError::Parse("binary u64 field is truncated".to_owned()))?; + Ok(if version == 0 { + u64::from_le_bytes(bytes) + } else { + u64::from_be_bytes(bytes) + }) +} + +/// Converts a `u32` length value into `usize` for buffer indexing. +fn u32_to_usize(value: u32) -> Result { + usize::try_from(value).map_err(|_| { + ControlFileError::Parse("binary length exceeds the supported platform size".to_owned()) + }) +} + +/// Adds two `usize` values and returns a parse error on overflow. +fn checked_add_usize(lhs: usize, rhs: usize, context: &str) -> Result { + lhs.checked_add(rhs) + .ok_or_else(|| ControlFileError::Parse(format!("{context} length overflowed"))) +} diff --git a/crates/aria2-rust-pro-storage/src/control/model.rs b/crates/aria2-rust-pro-storage/src/control/model.rs new file mode 100644 index 0000000..5cab104 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/control/model.rs @@ -0,0 +1,124 @@ +use std::{fmt, io}; + +use crate::{ + checksum::Checksum, + model::{DownloadFile, PieceIndex, PieceState}, +}; + +/// Version tag for the simplified control-file format. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ControlFileVersion { + /// Major version component. + major: u16, + /// Minor version component. + minor: u16, +} + +impl ControlFileVersion { + /// Current text-first control-file version. + pub const CURRENT: Self = Self { major: 1, minor: 0 }; + /// Binary-compatible control-file version. + pub const BINARY_V1: Self = Self { major: 1, minor: 1 }; + + /// Builds a version value from explicit major/minor parts. + #[must_use] + pub(crate) const fn from_parts(major: u16, minor: u16) -> Self { + Self { major, minor } + } + + /// Returns the major version component. + #[must_use] + pub const fn major(self) -> u16 { + self.major + } + + /// Returns the minor version component. + #[must_use] + pub const fn minor(self) -> u16 { + self.minor + } +} + +/// Normalized control metadata stored by the Rust implementation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ControlMetadata { + /// Version of the serialized control-file format. + pub version: ControlFileVersion, + /// Files tracked by the control metadata. + pub files: Vec, + /// Whole-download or file-level checksums. + pub checksums: Vec, + /// Non-default piece states captured by the downloader. + pub piece_states: Vec<(PieceIndex, PieceState)>, + /// Number of completed bytes known at serialization time. + pub completed_length: u64, + /// Number of retry attempts already consumed. + pub retry_count: u32, + /// Last runtime error, if one was recorded. + pub last_error: Option, + /// Timestamp for the last runtime error in Unix milliseconds. + pub last_error_at_unix_ms: Option, + /// Timestamp for the last retry attempt in Unix milliseconds. + pub last_retry_at_unix_ms: Option, + /// Timestamp for the next scheduled retry in Unix milliseconds. + pub next_retry_at_unix_ms: Option, + /// Number of consecutive transfer failures, when tracked. + pub consecutive_failure_count: Option, + /// Number of active download segments, when tracked. + pub active_segment_count: Option, + /// Timestamp when resume verification last completed. + pub resume_verified_at_unix_ms: Option, + /// Resume-generation counter used to correlate session state. + pub resume_generation: Option, +} + +/// Text control-file representation preserved for diagnostics. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ControlFileTextModel { + /// Raw control-file lines. + pub lines: Vec, +} + +/// Binary control-file representation preserved for diagnostics. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ControlFileBinaryModel { + /// Four-byte binary magic value. + pub magic: [u8; 4], + /// Encoded major version component. + pub version_major: u16, + /// Encoded minor version component. + pub version_minor: u16, + /// Binary payload after the header. + pub payload: Vec, +} + +/// Errors that can occur while reading or writing control files. +#[derive(Debug)] +pub enum ControlFileError { + /// Underlying filesystem I/O error. + Io(io::Error), + /// Malformed or unsupported control-file contents. + Parse(String), + /// Upstream binary format variant is unsupported. + UnsupportedBinaryCompatibility, +} + +impl fmt::Display for ControlFileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "io error: {error}"), + Self::Parse(message) => write!(f, "parse error: {message}"), + Self::UnsupportedBinaryCompatibility => { + write!(f, "unsupported binary .aria2 control-file format") + } + } + } +} + +impl std::error::Error for ControlFileError {} + +impl From for ControlFileError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} diff --git a/crates/aria2-rust-pro-storage/src/control/tests.rs b/crates/aria2-rust-pro-storage/src/control/tests.rs new file mode 100644 index 0000000..406c101 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/control/tests.rs @@ -0,0 +1,201 @@ +use std::{ + collections::BTreeMap, + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + checksum::Checksum, + model::{DownloadFile, PieceIndex, PieceState}, +}; + +use super::*; + +fn temp_control_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be monotonic enough for test naming") + .as_nanos(); + std::env::temp_dir().join(format!("aria2-rust-pro-storage-{name}-{nanos}.aria2")) +} + +fn upstream_binary_fixture(version: u16) -> Vec { + let mut bytes = Vec::new(); + match version { + 0 => { + bytes.extend_from_slice(&0_u16.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&1024_u32.to_le_bytes()); + bytes.extend_from_slice(&81_920_u64.to_le_bytes()); + bytes.extend_from_slice(&0_u64.to_le_bytes()); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe]); + bytes.extend_from_slice(&2_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + bytes.extend_from_slice(&1024_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + bytes.push(0x00); + bytes.extend_from_slice(&2_u32.to_le_bytes()); + bytes.extend_from_slice(&512_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + bytes.push(0x00); + } + 1 => { + bytes.extend_from_slice(&1_u16.to_be_bytes()); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend_from_slice(&1024_u32.to_be_bytes()); + bytes.extend_from_slice(&81_920_u64.to_be_bytes()); + bytes.extend_from_slice(&0_u64.to_be_bytes()); + bytes.extend_from_slice(&10_u32.to_be_bytes()); + bytes.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe]); + bytes.extend_from_slice(&2_u32.to_be_bytes()); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + bytes.extend_from_slice(&1024_u32.to_be_bytes()); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + bytes.push(0x00); + bytes.extend_from_slice(&2_u32.to_be_bytes()); + bytes.extend_from_slice(&512_u32.to_be_bytes()); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + bytes.push(0x00); + } + other => panic!("unexpected test fixture version: {other}"), + } + bytes +} + +fn piece_states_by_index(metadata: &ControlMetadata) -> BTreeMap { + metadata + .piece_states + .iter() + .map(|(index, state)| (index.0, *state)) + .collect() +} + +#[test] +fn control_metadata_roundtrip_preserves_runtime_fields() { + let metadata = ControlMetadata { + version: ControlFileVersion::CURRENT, + files: vec![DownloadFile { + path: PathBuf::from("D:/downloads/a.bin"), + length: 1024, + piece_length: 256, + }], + checksums: vec![Checksum::Sha256("abc123".to_owned())], + piece_states: vec![ + (PieceIndex(0), PieceState::Verified), + (PieceIndex(1), PieceState::InFlight), + ], + completed_length: 512, + retry_count: 3, + last_error: Some("timeout".to_owned()), + last_error_at_unix_ms: Some(1_700_000_000_001), + last_retry_at_unix_ms: Some(1_700_000_000_010), + next_retry_at_unix_ms: Some(1_700_000_000_020), + consecutive_failure_count: Some(2), + active_segment_count: Some(4), + resume_verified_at_unix_ms: Some(1_700_000_000_100), + resume_generation: Some(7), + }; + let encoded = encode_control_metadata(&metadata); + let decoded = decode_control_metadata(&encoded).unwrap(); + assert_eq!(decoded, metadata); +} + +#[test] +fn control_metadata_decode_keeps_backward_compat_defaults() { + let raw = "version=1.0\nfiles=0\nchecksums=0\ncompleted_length=12\nretry_count=1\npieces=0"; + let decoded = decode_control_metadata(raw).unwrap(); + assert_eq!(decoded.completed_length, 12); + assert_eq!(decoded.retry_count, 1); + assert_eq!(decoded.last_error, None); + assert_eq!(decoded.last_error_at_unix_ms, None); + assert_eq!(decoded.active_segment_count, None); + assert_eq!(decoded.resume_generation, None); +} + +#[test] +fn binary_control_reader_loads_upstream_v1_fixture() { + let path = temp_control_path("binary-v1-fixture.bin"); + fs::write(&path, upstream_binary_fixture(1)).unwrap(); + + let loaded = read_aria2_binary_control_file(&path).unwrap(); + let states = piece_states_by_index(&loaded); + let file = loaded + .files + .first() + .expect("binary control fixture should contain one file"); + + assert_eq!(loaded.files.len(), 1); + assert_eq!(file.path, path.with_extension("")); + assert_eq!(file.length, 81_920); + assert_eq!(file.piece_length, 1_024); + assert_eq!(loaded.completed_length, 80_896); + assert_eq!(states.get(&0), Some(&PieceState::Verified)); + assert_eq!(states.get(&1), Some(&PieceState::InFlight)); + assert_eq!(states.get(&2), Some(&PieceState::InFlight)); + assert_eq!(states.get(&78), Some(&PieceState::Verified)); + assert_eq!(states.get(&79), None); + + let _ = fs::remove_file(path); +} + +#[test] +fn control_file_reader_auto_detects_upstream_v0_binary_fixture() { + let path = temp_control_path("binary-v0-fixture.bin"); + fs::write(&path, upstream_binary_fixture(0)).unwrap(); + + let loaded = read_aria2_control_file(&path).unwrap(); + let states = piece_states_by_index(&loaded); + let file = loaded + .files + .first() + .expect("binary control fixture should contain one file"); + + assert_eq!(loaded.files.len(), 1); + assert_eq!(file.path, path.with_extension("")); + assert_eq!(file.length, 81_920); + assert_eq!(file.piece_length, 1_024); + assert_eq!(loaded.completed_length, 80_896); + assert_eq!(states.get(&0), Some(&PieceState::Verified)); + assert_eq!(states.get(&1), Some(&PieceState::InFlight)); + assert_eq!(states.get(&2), Some(&PieceState::InFlight)); + + let _ = fs::remove_file(path); +} + +#[test] +fn control_file_roundtrip_preserves_multiline_runtime_error() { + let metadata = ControlMetadata { + version: ControlFileVersion::CURRENT, + files: vec![DownloadFile { + path: PathBuf::from("D:/downloads/a.bin"), + length: 1024, + piece_length: 256, + }], + checksums: Vec::new(), + piece_states: vec![(PieceIndex(0), PieceState::Verified)], + completed_length: 128, + retry_count: 2, + last_error: Some("timeout\nmirror=2;retry".to_owned()), + last_error_at_unix_ms: Some(1_700_000_001_111), + last_retry_at_unix_ms: Some(1_700_000_001_222), + next_retry_at_unix_ms: Some(1_700_000_001_333), + consecutive_failure_count: Some(4), + active_segment_count: Some(6), + resume_verified_at_unix_ms: Some(1_700_000_001_444), + resume_generation: Some(12), + }; + let path = temp_control_path("roundtrip"); + write_aria2_control_file(&path, &metadata).unwrap(); + let raw = fs::read(&path).unwrap(); + assert!( + raw.starts_with(&[0x00, 0x01]), + "control file should start with the upstream binary v0001 header" + ); + let loaded = read_aria2_control_file(&path).unwrap(); + assert_eq!(loaded, metadata); + let _ = fs::remove_file(path); +} diff --git a/crates/aria2-rust-pro-storage/src/control/text.rs b/crates/aria2-rust-pro-storage/src/control/text.rs new file mode 100644 index 0000000..5f90194 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/control/text.rs @@ -0,0 +1,315 @@ +use std::path::PathBuf; + +use crate::{ + checksum::Checksum, + model::{DownloadFile, PieceIndex, PieceState}, +}; + +use super::model::{ControlFileError, ControlFileVersion, ControlMetadata}; + +/// Encodes normalized metadata into the simplified text control-file format. +#[must_use] +pub fn encode_control_metadata(metadata: &ControlMetadata) -> String { + let mut lines = Vec::new(); + lines.push(format!( + "version={}.{}", + metadata.version.major(), + metadata.version.minor() + )); + lines.push(format!("files={}", metadata.files.len())); + for file in &metadata.files { + lines.push(format!( + "file={}|{}|{}", + file.path.display(), + file.length, + file.piece_length + )); + } + lines.push(format!("checksums={}", metadata.checksums.len())); + for checksum in &metadata.checksums { + lines.push(format!("checksum={}", encode_checksum(checksum))); + } + lines.push(format!("completed_length={}", metadata.completed_length)); + lines.push(format!("retry_count={}", metadata.retry_count)); + if let Some(value) = &metadata.last_error { + lines.push(format!("last_error={}", escape_control_field(value))); + } + if let Some(value) = metadata.last_error_at_unix_ms { + lines.push(format!("last_error_at_unix_ms={value}")); + } + if let Some(value) = metadata.last_retry_at_unix_ms { + lines.push(format!("last_retry_at_unix_ms={value}")); + } + if let Some(value) = metadata.next_retry_at_unix_ms { + lines.push(format!("next_retry_at_unix_ms={value}")); + } + if let Some(value) = metadata.consecutive_failure_count { + lines.push(format!("consecutive_failure_count={value}")); + } + if let Some(value) = metadata.active_segment_count { + lines.push(format!("active_segment_count={value}")); + } + if let Some(value) = metadata.resume_verified_at_unix_ms { + lines.push(format!("resume_verified_at_unix_ms={value}")); + } + if let Some(value) = metadata.resume_generation { + lines.push(format!("resume_generation={value}")); + } + lines.push(format!("pieces={}", metadata.piece_states.len())); + for (index, state) in &metadata.piece_states { + lines.push(format!("piece={}|{}", index.0, encode_piece_state(*state))); + } + lines.join("\n") +} + +/// Decodes the simplified control-file text format. +/// +/// # Errors +/// +/// Returns [`ControlFileError`] when the encoded text is malformed. +#[expect( + clippy::too_many_lines, + reason = "text control metadata decoding keeps legacy and current field handling in one ordered parser" +)] +pub fn decode_control_metadata(content: &str) -> Result { + let mut version = ControlFileVersion::CURRENT; + let mut files = Vec::new(); + let mut checksums = Vec::new(); + let mut piece_states = Vec::new(); + let mut completed_length = 0_u64; + let mut retry_count = 0_u32; + let mut last_error = None; + let mut last_error_at_unix_ms = None; + let mut last_retry_at_unix_ms = None; + let mut next_retry_at_unix_ms = None; + let mut consecutive_failure_count = None; + let mut active_segment_count = None; + let mut resume_verified_at_unix_ms = None; + let mut resume_generation = None; + + for line in content.lines() { + if let Some(raw) = line.strip_prefix("version=") { + let mut parts = raw.split('.'); + let major = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing version major".to_owned()))? + .parse::() + .map_err(|_| ControlFileError::Parse("invalid version major".to_owned()))?; + let minor = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing version minor".to_owned()))? + .parse::() + .map_err(|_| ControlFileError::Parse("invalid version minor".to_owned()))?; + version = ControlFileVersion::from_parts(major, minor); + continue; + } + if let Some(raw) = line.strip_prefix("file=") { + let mut parts = raw.split('|'); + let path = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing file path".to_owned()))?; + let length = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing file length".to_owned()))? + .parse::() + .map_err(|_| ControlFileError::Parse("invalid file length".to_owned()))?; + let piece_length = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing file piece_length".to_owned()))? + .parse::() + .map_err(|_| ControlFileError::Parse("invalid file piece_length".to_owned()))?; + files.push(DownloadFile { + path: PathBuf::from(path), + length, + piece_length, + }); + continue; + } + if let Some(raw) = line.strip_prefix("checksum=") { + checksums.push(decode_checksum(raw)?); + continue; + } + if let Some(raw) = line.strip_prefix("completed_length=") { + completed_length = raw + .parse::() + .map_err(|_| ControlFileError::Parse("invalid completed_length".to_owned()))?; + continue; + } + if let Some(raw) = line.strip_prefix("retry_count=") { + retry_count = raw + .parse::() + .map_err(|_| ControlFileError::Parse("invalid retry_count".to_owned()))?; + continue; + } + if let Some(raw) = line.strip_prefix("last_error=") { + last_error = Some(unescape_control_field(raw)); + continue; + } + if let Some(raw) = line.strip_prefix("last_error_at_unix_ms=") { + last_error_at_unix_ms = Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid last_error_at_unix_ms".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("last_retry_at_unix_ms=") { + last_retry_at_unix_ms = Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid last_retry_at_unix_ms".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("next_retry_at_unix_ms=") { + next_retry_at_unix_ms = Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid next_retry_at_unix_ms".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("consecutive_failure_count=") { + consecutive_failure_count = Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid consecutive_failure_count".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("active_segment_count=") { + active_segment_count = + Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid active_segment_count".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("resume_verified_at_unix_ms=") { + resume_verified_at_unix_ms = Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid resume_verified_at_unix_ms".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("resume_generation=") { + resume_generation = + Some(raw.parse::().map_err(|_| { + ControlFileError::Parse("invalid resume_generation".to_owned()) + })?); + continue; + } + if let Some(raw) = line.strip_prefix("piece=") { + let mut parts = raw.split('|'); + let index = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing piece index".to_owned()))? + .parse::() + .map_err(|_| ControlFileError::Parse("invalid piece index".to_owned()))?; + let state = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing piece state".to_owned())) + .and_then(decode_piece_state)?; + piece_states.push((PieceIndex(index), state)); + } + } + + Ok(ControlMetadata { + version, + files, + checksums, + piece_states, + completed_length, + retry_count, + last_error, + last_error_at_unix_ms, + last_retry_at_unix_ms, + next_retry_at_unix_ms, + consecutive_failure_count, + active_segment_count, + resume_verified_at_unix_ms, + resume_generation, + }) +} + +/// Encodes a piece state for the text control-file format. +const fn encode_piece_state(state: PieceState) -> &'static str { + match state { + PieceState::Pending => "pending", + PieceState::InFlight => "in-flight", + PieceState::Verified => "verified", + PieceState::Failed => "failed", + } +} + +/// Decodes a piece state from the text control-file format. +fn decode_piece_state(raw: &str) -> Result { + match raw { + "pending" => Ok(PieceState::Pending), + "in-flight" => Ok(PieceState::InFlight), + "verified" => Ok(PieceState::Verified), + "failed" => Ok(PieceState::Failed), + _ => Err(ControlFileError::Parse("invalid piece state".to_owned())), + } +} + +/// Encodes a checksum into the text control-file format. +fn encode_checksum(checksum: &Checksum) -> String { + match checksum { + Checksum::Sha1(value) => format!("sha1:{value}"), + Checksum::Sha256(value) => format!("sha256:{value}"), + Checksum::Md5(value) => format!("md5:{value}"), + Checksum::Adler32(value) => format!("adler32:{value}"), + Checksum::Crc32(value) => format!("crc32:{value}"), + } +} + +/// Decodes a checksum from the text control-file format. +fn decode_checksum(raw: &str) -> Result { + let mut parts = raw.splitn(2, ':'); + let algorithm = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing checksum algorithm".to_owned()))?; + let value = parts + .next() + .ok_or_else(|| ControlFileError::Parse("missing checksum value".to_owned()))? + .to_owned(); + + match algorithm { + "sha1" => Ok(Checksum::Sha1(value)), + "sha256" => Ok(Checksum::Sha256(value)), + "md5" => Ok(Checksum::Md5(value)), + "adler32" => Ok(Checksum::Adler32(value)), + "crc32" => Ok(Checksum::Crc32(value)), + _ => Err(ControlFileError::Parse( + "unsupported checksum algorithm".to_owned(), + )), + } +} + +/// Escapes free-form text fields embedded in a control file. +fn escape_control_field(raw: &str) -> String { + raw.replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace(';', "\\s") + .replace(',', "\\c") + .replace('=', "\\e") +} + +/// Reverses [`escape_control_field`] for text control-file fields. +fn unescape_control_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('t') => out.push('\t'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('s') => out.push(';'), + Some('c') => out.push(','), + Some('e') => out.push('='), + Some('\\') | None => out.push('\\'), + Some(other) => { + out.push('\\'); + out.push(other); + } + } + } else { + out.push(ch); + } + } + out +} diff --git a/crates/aria2-rust-pro-storage/src/disk.rs b/crates/aria2-rust-pro-storage/src/disk.rs new file mode 100644 index 0000000..e04ada2 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/disk.rs @@ -0,0 +1,69 @@ +use std::io; + +use crate::model::PieceIndex; + +/// References a byte range inside a piece. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ChunkRef { + /// Piece that owns the referenced bytes. + pub piece: PieceIndex, + /// Piece-relative offset where the chunk begins. + pub offset: u64, + /// Chunk length in bytes. + pub length: u64, +} + +/// Persists chunk payloads into some backing sink. +pub trait ChunkWriter { + /// Concrete error type returned by the writer. + type Error; + + /// Persists a payload segment for the referenced piece span. + /// + /// # Errors + /// + /// Returns the writer-specific error when the chunk cannot be stored. + fn write_chunk(&mut self, chunk: &ChunkRef, payload: &[u8]) -> Result<(), Self::Error>; +} + +/// Adds disk-specific durability operations for chunk writers. +pub trait DiskChunkWriter { + /// Flushes buffered writes to the underlying disk sink. + /// + /// # Errors + /// + /// Returns an I/O error when buffered state cannot be flushed. + fn flush(&mut self) -> Result<(), io::Error>; + /// Requests that file data is synchronized to stable storage. + /// + /// # Errors + /// + /// Returns an I/O error when the synchronization request fails. + fn sync_data(&mut self) -> Result<(), io::Error>; +} + +/// Reads chunk payloads from durable storage. +pub trait DiskChunkReader { + /// Reads a previously written chunk span from storage. + /// + /// # Errors + /// + /// Returns an I/O error when the requested chunk cannot be read. + fn read_chunk(&mut self, chunk: &ChunkRef) -> Result, io::Error>; +} + +/// Test-oriented chunk writer that records all writes in memory. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MemoryChunkWriter { + /// Sequence of chunk writes that were requested. + pub writes: Vec<(ChunkRef, Vec)>, +} + +impl ChunkWriter for MemoryChunkWriter { + type Error = io::Error; + + fn write_chunk(&mut self, chunk: &ChunkRef, payload: &[u8]) -> Result<(), Self::Error> { + self.writes.push((*chunk, payload.to_vec())); + Ok(()) + } +} diff --git a/crates/aria2-rust-pro-storage/src/io.rs b/crates/aria2-rust-pro-storage/src/io.rs new file mode 100644 index 0000000..1806f7b --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/io.rs @@ -0,0 +1,317 @@ +use std::{ + collections::BTreeMap, + fs::{File, OpenOptions}, + io::{self, Write}, + path::PathBuf, +}; + +use crate::{disk::ChunkRef, model::PieceIndex}; + +/// Describes one memory-mapped span for a piece. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MmapIndex { + /// File that contains the mapped bytes. + pub file: PathBuf, + /// Absolute file offset where the mapping begins. + pub offset: u64, + /// Mapped byte length. + pub length: u64, +} + +/// Stores all file mappings that belong to each piece. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PieceIndexLookup { + /// Mapping list keyed by piece index. + pub by_piece: BTreeMap>, +} + +/// Writes piece-relative byte ranges into a backing store. +pub trait RangeChunkWriter { + /// Concrete error type returned by the writer. + type Error; + + /// Writes a payload range into the mapped piece space. + /// + /// # Errors + /// + /// Returns the implementation-specific error when the range cannot be written. + fn write_range( + &mut self, + piece: PieceIndex, + piece_offset: u64, + payload: &[u8], + ) -> Result; +} + +/// Reads piece-relative byte ranges from a backing store. +pub trait RangeChunkReader { + /// Concrete error type returned by the reader. + type Error; + + /// Reads a payload range from the mapped piece space. + /// + /// # Errors + /// + /// Returns the implementation-specific error when the range cannot be read. + fn read_range( + &mut self, + piece: PieceIndex, + piece_offset: u64, + len: u64, + ) -> Result, Self::Error>; +} + +/// Appends raw bytes into an output sink. +pub trait ByteSink { + /// Concrete error type returned by the sink. + type Error; + + /// Appends bytes into the sink. + /// + /// # Errors + /// + /// Returns the sink-specific error when the payload cannot be persisted. + fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error>; +} + +/// Observed sink that writes to a file and mirrors the bytes in memory. +#[derive(Debug)] +pub struct ObservedFileSink { + /// Backing file path. + path: PathBuf, + /// Concrete writer used to persist the payload. + file: W, + /// In-memory observation state. + observed: ObservedByteSink, +} + +/// Observed sink that tracks the bytes written through it. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ObservedByteSink { + /// Total number of bytes written through the sink. + observed_len: u64, + /// Retained suffix of the observed byte stream. + retained: Vec, + /// Optional cap for retained bytes. + retention_limit: Option, +} + +impl ObservedByteSink { + /// Creates an observed sink that retains all bytes. + #[must_use] + pub fn with_unbounded_retention() -> Self { + Self::default() + } + + /// Creates an observed sink that retains at most `retention_limit` bytes. + #[must_use] + pub fn with_retention_limit(retention_limit: usize) -> Self { + Self { + retention_limit: Some(retention_limit), + ..Self::default() + } + } + + /// Returns the total number of bytes observed so far. + #[must_use] + pub const fn observed_len(&self) -> u64 { + self.observed_len + } + + /// Returns the retained suffix of the observed byte stream. + #[must_use] + pub const fn retained(&self) -> &[u8] { + self.retained.as_slice() + } +} + +impl ObservedFileSink { + /// Creates a file-backed observed sink at the provided path. + /// + /// # Errors + /// + /// Returns an I/O error when the file cannot be created or truncated. + pub fn create(path: impl Into) -> Result { + let path = path.into(); + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&path)?; + Ok(Self { + path, + file, + observed: ObservedByteSink::with_unbounded_retention(), + }) + } +} + +impl ObservedFileSink { + /// Returns the backing file path. + #[must_use] + pub const fn path(&self) -> &PathBuf { + &self.path + } + + /// Returns the total number of bytes written through the sink. + #[must_use] + pub const fn observed_len(&self) -> u64 { + self.observed.observed_len() + } + + /// Returns the retained suffix of the observed byte stream. + #[must_use] + pub const fn retained(&self) -> &[u8] { + self.observed.retained() + } + + #[cfg(test)] + fn with_writer(path: impl Into, file: W) -> Self { + Self { + path: path.into(), + file, + observed: ObservedByteSink::with_unbounded_retention(), + } + } +} + +impl ByteSink for ObservedFileSink { + type Error = io::Error; + + fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + self.file.write_all(payload)?; + let _ = ByteSink::write(&mut self.observed, payload); + Ok(()) + } +} + +impl Write for ObservedFileSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + ::write(self, buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} + +impl ByteSink for ObservedByteSink { + type Error = std::convert::Infallible; + + fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + let payload_len = u64::try_from(payload.len()).unwrap_or(u64::MAX); + self.observed_len = self.observed_len.saturating_add(payload_len); + self.retained.extend_from_slice(payload); + + if let Some(limit) = self.retention_limit + && self.retained.len() > limit + { + let drop_len = self.retained.len().saturating_sub(limit); + self.retained.drain(..drop_len); + } + + Ok(()) + } +} + +impl Write for ObservedByteSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + let _ = ::write(self, buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl PieceIndexLookup { + /// Registers one file mapping for the provided piece. + pub fn add_mapping(&mut self, piece: PieceIndex, mmap_index: MmapIndex) { + self.by_piece.entry(piece).or_default().push(mmap_index); + } + + /// Returns all mappings known for the provided piece. + #[must_use] + pub fn mappings(&self, piece: PieceIndex) -> &[MmapIndex] { + self.by_piece.get(&piece).map_or(&[], Vec::as_slice) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, io}; + + use super::{ByteSink, ObservedByteSink, ObservedFileSink}; + + #[test] + fn observed_sink_tracks_observed_len_and_retains_payload() { + let mut sink = ObservedByteSink::with_unbounded_retention(); + sink.write(b"hello").expect("infallible write"); + sink.write(b"-world").expect("infallible write"); + + assert_eq!(sink.observed_len(), 11); + assert_eq!(sink.retained(), b"hello-world"); + } + + #[test] + fn observed_sink_respects_retention_limit() { + let mut sink = ObservedByteSink::with_retention_limit(4); + sink.write(b"abcdef").expect("infallible write"); + + assert_eq!(sink.observed_len(), 6); + assert_eq!(sink.retained(), b"cdef"); + } + + #[test] + fn observed_sink_applies_limit_across_multiple_writes() { + let mut sink = ObservedByteSink::with_retention_limit(5); + sink.write(b"ab").expect("infallible write"); + sink.write(b"cde").expect("infallible write"); + sink.write(b"fgh").expect("infallible write"); + + assert_eq!(sink.observed_len(), 8); + assert_eq!(sink.retained(), b"defgh"); + } + + #[test] + fn observed_file_sink_writes_payload_and_tracks_observation() { + let root = std::env::temp_dir().join("aria2-rust-pro-observed-file-sink-test.bin"); + let _ = fs::remove_file(&root); + let mut sink = ObservedFileSink::create(&root).expect("file sink should create"); + sink.write(b"abc").expect("file write should work"); + sink.write(b"def").expect("file write should work"); + + assert_eq!(sink.observed_len(), 6); + assert_eq!(sink.retained(), b"abcdef"); + assert_eq!(fs::read(&root).expect("file should exist"), b"abcdef"); + let _ = fs::remove_file(&root); + } + + #[derive(Debug, Default)] + struct FailingWriter; + + impl io::Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new( + io::ErrorKind::WriteZero, + "synthetic write failure", + )) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn observed_file_sink_propagates_writer_failure_without_advancing_observation() { + let mut sink = ObservedFileSink::with_writer("synthetic.bin", FailingWriter); + let err = ByteSink::write(&mut sink, b"abc").expect_err("write should fail"); + + assert_eq!(err.kind(), io::ErrorKind::WriteZero); + assert_eq!(sink.observed_len(), 0); + assert!(sink.retained().is_empty()); + } +} diff --git a/crates/aria2-rust-pro-storage/src/lib.rs b/crates/aria2-rust-pro-storage/src/lib.rs new file mode 100644 index 0000000..9fe4f2e --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/lib.rs @@ -0,0 +1,50 @@ +//! Storage-side data models and persistence helpers for `aria2-rust-pro`. +//! +//! This crate keeps the storage-facing contracts small and serializable so the +//! downloader, session, and disk layers can share a stable representation. + +#![forbid(unsafe_code)] + +/// File allocation planning primitives. +mod allocation; +/// In-memory cache structures for piece payloads. +mod cache; +/// Checksum and verification abstractions. +mod checksum; +/// `.aria2` control-file encoding and decoding. +mod control; +/// Chunk-oriented disk read and write traits. +mod disk; +/// Byte sinks and range-based storage I/O helpers. +mod io; +/// Shared storage-domain models. +mod model; +/// Resume data contracts. +mod resume; +/// Session file encoding and decoding helpers. +mod session; +/// Local filesystem-backed store implementations. +mod store; + +pub use allocation::{AllocationMode, FileAllocation, PreallocationPlan, build_preallocation_plan}; +pub use cache::{CacheConfig, CacheEntry, ChunkCacheIndex, DiskCache}; +pub use checksum::{ + Checksum, ChecksumVerifier, HashAlgorithm, HashDigest, HasherFactory, PieceHashVerifier, + VerificationResult, +}; +pub use control::{ + ControlFileBinaryModel, ControlFileError, ControlFileTextModel, ControlFileVersion, + ControlMetadata, decode_control_metadata, encode_control_metadata, + read_aria2_binary_control_file, read_aria2_control_file, write_aria2_control_file, +}; +pub use disk::{ChunkRef, ChunkWriter, DiskChunkReader, DiskChunkWriter, MemoryChunkWriter}; +pub use io::{ + ByteSink, MmapIndex, ObservedByteSink, ObservedFileSink, PieceIndexLookup, RangeChunkReader, + RangeChunkWriter, +}; +pub use model::{DownloadFile, FileEntry, FileLayout, Piece, PieceIndex, PieceMap, PieceState}; +pub use resume::{ResumeData, ResumeSnapshot, ResumeStore}; +pub use session::{ + Aria2MetadataState, SessionFile, SessionFileEntry, load_session_file, save_session_file, +}; +pub use store::{ControlStore, LocalFileStore, LocalStoreError, MetadataStore, SessionStore}; diff --git a/crates/aria2-rust-pro-storage/src/model.rs b/crates/aria2-rust-pro-storage/src/model.rs new file mode 100644 index 0000000..f4bd53e --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/model.rs @@ -0,0 +1,132 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +/// Identifies a piece by its zero-based index. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PieceIndex(pub u32); + +/// Tracks the lifecycle state of a piece. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PieceState { + /// Piece has not been scheduled or verified yet. + Pending, + /// Piece is currently being downloaded or verified. + InFlight, + /// Piece payload has been verified successfully. + Verified, + /// Piece failed to download or verify. + Failed, +} + +/// Describes one piece span within a download. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Piece { + /// Zero-based piece identifier. + pub index: PieceIndex, + /// Absolute byte offset where the piece begins. + pub offset: u64, + /// Piece length in bytes. + pub length: u64, + /// Current piece lifecycle state. + pub state: PieceState, +} + +/// Describes one file segment inside the logical download layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileEntry { + /// Zero-based file index inside the layout. + pub index: u32, + /// Final file path. + pub path: PathBuf, + /// Absolute byte offset where the file begins. + pub offset: u64, + /// File length in bytes. + pub length: u64, +} + +/// Maps logical download bytes onto output files. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FileLayout { + /// Ordered file entries covering the full download span. + pub entries: Vec, + /// Total logical download length in bytes. + pub total_length: u64, + /// Nominal piece length in bytes. + pub piece_length: u64, +} + +impl FileLayout { + /// Returns the number of pieces needed to cover the layout. + /// + /// When the logical piece count exceeds `u32::MAX`, the value saturates at + /// `u32::MAX`. + #[must_use] + pub fn piece_count(&self) -> u32 { + if self.piece_length == 0 { + return 0; + } + u32::try_from(self.total_length.div_ceil(self.piece_length)).unwrap_or(u32::MAX) + } +} + +/// Tracks non-default piece states by piece index. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PieceMap { + /// Sparse map of explicitly stored piece states. + states: BTreeMap, +} + +impl PieceMap { + /// Returns the current state for the provided piece index. + #[must_use] + pub fn state(&self, index: PieceIndex) -> PieceState { + self.states + .get(&index) + .copied() + .unwrap_or(PieceState::Pending) + } + + /// Stores the state for a piece index. + pub fn set_state(&mut self, index: PieceIndex, state: PieceState) { + self.states.insert(index, state); + } + + /// Iterates over piece states that have been explicitly stored. + pub fn iter(&self) -> impl Iterator { + self.states.iter() + } + + /// Sums the verified bytes represented by the current piece map. + #[must_use] + pub fn completed_verified_bytes(&self, piece_length: u64, total_length: u64) -> u64 { + self.states + .iter() + .filter(|(_, state)| **state == PieceState::Verified) + .map(|(index, _)| piece_span_bytes(index.0, piece_length, total_length)) + .sum() + } +} + +/// Normalized file information used by control metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DownloadFile { + /// Final output path for the file. + pub path: PathBuf, + /// File length in bytes. + pub length: u64, + /// Piece length used by the containing download. + pub piece_length: u64, +} + +/// Returns the byte length covered by a piece index. +fn piece_span_bytes(index: u32, piece_length: u64, total_length: u64) -> u64 { + let Some(start) = u64::from(index).checked_mul(piece_length) else { + return 0; + }; + if start >= total_length { + return 0; + } + let Some(remaining) = total_length.checked_sub(start) else { + return 0; + }; + remaining.min(piece_length) +} diff --git a/crates/aria2-rust-pro-storage/src/resume.rs b/crates/aria2-rust-pro-storage/src/resume.rs new file mode 100644 index 0000000..14e6bd3 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/resume.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use crate::{ + control::ControlMetadata, + model::{PieceMap, PieceState}, +}; + +/// Persisted resume payload for a single download. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResumeData { + /// Download gid that owns this resume state. + pub gid: String, + /// Final download path tracked by the resumer. + pub download_path: PathBuf, + /// Decoded control metadata, when available. + pub metadata: Option, + /// Piece-state map captured for the download. + pub piece_map: PieceMap, +} + +/// Flattened summary of resumable piece groups. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResumeSnapshot { + /// Download gid that owns this snapshot. + pub gid: String, + /// Piece indexes that are fully verified. + pub verified_pieces: Vec, + /// Piece indexes that are currently in flight. + pub inflight_pieces: Vec, + /// Piece indexes that failed verification or transfer. + pub failed_pieces: Vec, +} + +/// Persists and retrieves resume state for downloads. +pub trait ResumeStore { + /// Concrete error type returned by the store implementation. + type Error; + + /// Loads any persisted resume state for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when persisted resume state cannot be read. + fn load(&self, gid: &str) -> Result, Self::Error>; + /// Persists resume state for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when resume state cannot be written. + fn save(&self, resume: &ResumeData) -> Result<(), Self::Error>; + /// Removes persisted resume state for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when the persisted resume state cannot be removed. + fn remove(&self, gid: &str) -> Result<(), Self::Error>; +} + +impl ResumeSnapshot { + /// Builds a flattened snapshot from the full resume payload. + #[must_use] + pub fn from_resume_data(data: &ResumeData) -> Self { + let mut verified_pieces = Vec::new(); + let mut inflight_pieces = Vec::new(); + let mut failed_pieces = Vec::new(); + for (index, state) in data.piece_map.iter() { + match state { + PieceState::Verified => verified_pieces.push(index.0), + PieceState::InFlight => inflight_pieces.push(index.0), + PieceState::Failed => failed_pieces.push(index.0), + PieceState::Pending => {} + } + } + Self { + gid: data.gid.clone(), + verified_pieces, + inflight_pieces, + failed_pieces, + } + } +} diff --git a/crates/aria2-rust-pro-storage/src/session.rs b/crates/aria2-rust-pro-storage/src/session.rs new file mode 100644 index 0000000..5a03f52 --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/session.rs @@ -0,0 +1,330 @@ +use std::{ + collections::BTreeMap, + fs, io, + path::{Path, PathBuf}, +}; + +/// One download entry inside a session file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionFileEntry { + /// Download gid. + pub gid: String, + /// Primary download URI. + pub uri: String, + /// All known mirror URIs for the download. + pub uris: Vec, + /// Target output path. + pub target_path: PathBuf, + /// Optional path to sidecar metadata. + pub metadata_path: Option, + /// Additional key-value metadata preserved by the session file. + pub metadata: Option>, +} + +/// Serialized session file contents. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SessionFile { + /// Download entries stored in the session file. + pub entries: Vec, +} + +/// Metadata key-value state tracked for one gid. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Aria2MetadataState { + /// Download gid. + pub gid: String, + /// Metadata key-value pairs. + pub kv: BTreeMap, +} + +/// Writes the simplified session file format used by the Rust implementation. +/// +/// # Errors +/// +/// Returns an I/O error when the session file cannot be written. +pub fn save_session_file(path: &Path, session: &SessionFile) -> Result<(), io::Error> { + let mut lines = Vec::new(); + for entry in &session.entries { + let uris = if entry.uris.is_empty() { + vec![entry.uri.clone()] + } else { + entry.uris.clone() + }; + let metadata_kv = entry + .metadata + .as_ref() + .map(|kv| { + kv.iter() + .map(|(k, v)| format!("{}={}", escape_field(k), escape_field(v))) + .collect::>() + .join(";") + }) + .unwrap_or_default(); + lines.push(format!( + "v2\t{}\t{}\t{}\t{}\t{}", + escape_field(&entry.gid), + uris.iter() + .map(|uri| escape_field(uri)) + .collect::>() + .join(","), + escape_field(entry.target_path.to_string_lossy().as_ref()), + entry + .metadata_path + .as_ref() + .map(|p| escape_field(p.to_string_lossy().as_ref())) + .unwrap_or_default(), + metadata_kv + )); + } + fs::write(path, lines.join("\n")) +} + +/// Loads a simplified session file from disk. +/// +/// # Errors +/// +/// Returns an I/O error when the session file cannot be read. +pub fn load_session_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let mut entries = Vec::new(); + for line in content.lines().filter(|line| !line.trim().is_empty()) { + if let Some(payload) = line.strip_prefix("v2\t") { + let mut parts = payload.splitn(5, '\t'); + let gid = unescape_field(parts.next().unwrap_or_default()); + let uris_raw = parts.next().unwrap_or_default(); + let uris = if uris_raw.is_empty() { + Vec::new() + } else { + uris_raw.split(',').map(unescape_field).collect::>() + }; + let uri = uris.first().cloned().unwrap_or_default(); + let target_path = PathBuf::from(unescape_field(parts.next().unwrap_or_default())); + let metadata_path = match parts.next() { + Some(raw) if !raw.is_empty() => Some(PathBuf::from(unescape_field(raw))), + _ => None, + }; + let metadata = parse_metadata_map(parts.next().unwrap_or_default()); + entries.push(SessionFileEntry { + gid, + uri, + uris, + target_path, + metadata_path, + metadata, + }); + continue; + } + + let mut parts = line.splitn(4, '\t'); + let gid = parts.next().unwrap_or_default().to_owned(); + let uri = parts.next().unwrap_or_default().to_owned(); + let target_path = PathBuf::from(parts.next().unwrap_or_default()); + let metadata_path = match parts.next() { + Some(raw) if !raw.is_empty() => Some(PathBuf::from(raw)), + _ => None, + }; + entries.push(SessionFileEntry { + gid, + uri: uri.clone(), + uris: if uri.is_empty() { + Vec::new() + } else { + vec![uri] + }, + target_path, + metadata_path, + metadata: None, + }); + } + Ok(SessionFile { entries }) +} + +/// Parses the serialized metadata map payload from a session entry. +fn parse_metadata_map(raw: &str) -> Option> { + if raw.is_empty() { + return None; + } + let mut out = BTreeMap::new(); + for pair in raw.split(';') { + if pair.is_empty() { + continue; + } + let mut parts = pair.splitn(2, '='); + let key = unescape_field(parts.next().unwrap_or_default()); + let value = unescape_field(parts.next().unwrap_or_default()); + out.insert(key, value); + } + Some(out) +} + +/// Escapes field separators used by the session file format. +fn escape_field(raw: &str) -> String { + raw.replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace(';', "\\s") + .replace(',', "\\c") + .replace('=', "\\e") +} + +/// Reverses [`escape_field`] for a serialized session field. +fn unescape_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('t') => out.push('\t'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('s') => out.push(';'), + Some('c') => out.push(','), + Some('e') => out.push('='), + Some('\\') | None => out.push('\\'), + Some(other) => { + out.push('\\'); + out.push(other); + } + } + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_roundtrip_preserves_metadata_extensions() { + let mut metadata = BTreeMap::new(); + metadata.insert("etag".to_owned(), "abc=123".to_owned()); + metadata.insert( + "aria2.resume_path".to_owned(), + "D:/downloads/file.resume".to_owned(), + ); + metadata.insert("aria2.segment_count_hint".to_owned(), "16".to_owned()); + metadata.insert("aria2.resume_generation".to_owned(), "9".to_owned()); + metadata.insert( + "aria2.last_runtime_error".to_owned(), + "timeout on mirror #2".to_owned(), + ); + let session = SessionFile { + entries: vec![SessionFileEntry { + gid: "gid-1".to_owned(), + uri: "https://a.example/file".to_owned(), + uris: vec![ + "https://a.example/file".to_owned(), + "https://b.example/file".to_owned(), + ], + target_path: PathBuf::from("D:/downloads/file.bin"), + metadata_path: Some(PathBuf::from("D:/downloads/file.meta")), + metadata: Some(metadata), + }], + }; + let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-roundtrip.txt"); + save_session_file(&path, &session).unwrap(); + let loaded = load_session_file(&path).unwrap(); + assert_eq!(loaded, session); + let _ = fs::remove_file(path); + } + + #[test] + fn session_v2_decode_defaults_new_fields() { + let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-compat.txt"); + fs::write( + &path, + "v2\tgid-2\thttps://a.example/file\tD:/downloads/file.bin\t\t", + ) + .unwrap(); + let loaded = load_session_file(&path).unwrap(); + assert_eq!(loaded.entries.len(), 1); + let entry = loaded + .entries + .first() + .expect("single v2 entry should be present"); + assert_eq!(entry.gid, "gid-2"); + let _ = fs::remove_file(path); + } + + #[test] + fn session_roundtrip_with_multiple_uris_and_escaped_metadata() { + let mut metadata = BTreeMap::new(); + metadata.insert( + "meta;key=1".to_owned(), + "line1\nline2\twith\\slash,semi;eq=".to_owned(), + ); + metadata.insert("plain".to_owned(), "value".to_owned()); + let session = SessionFile { + entries: vec![SessionFileEntry { + gid: "gid-escaped".to_owned(), + uri: "https://a.example/file?x=1,y=2".to_owned(), + uris: vec![ + "https://a.example/file?x=1,y=2".to_owned(), + "https://b.example/file;alt=1".to_owned(), + "https://c.example/file\\mirror".to_owned(), + ], + target_path: PathBuf::from("D:/downloads/escaped-file.bin"), + metadata_path: Some(PathBuf::from("D:/downloads/escaped-file.meta")), + metadata: Some(metadata), + }], + }; + let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-escaped-roundtrip.txt"); + save_session_file(&path, &session).unwrap(); + let loaded = load_session_file(&path).unwrap(); + assert_eq!(loaded, session); + let _ = fs::remove_file(path); + } + + #[test] + fn load_session_file_supports_mixed_legacy_and_v2_lines() { + let path = std::env::temp_dir().join("aria2-rust-pro-session-mixed-compat.txt"); + let mixed = concat!( + "legacy-gid\thttps://legacy.example/file\tD:/downloads/legacy.bin\t\n", + "v2\tgid-v2\thttps://a.example/file,https://b.example/file\\cwith-comma\tD:/downloads/v2.bin\tD:/downloads/v2.meta\tk\\e1=v\\s1\n" + ); + fs::write(&path, mixed).unwrap(); + + let loaded = load_session_file(&path).unwrap(); + assert_eq!(loaded.entries.len(), 2); + + let legacy = loaded + .entries + .first() + .expect("legacy entry should be present"); + assert_eq!(legacy.gid, "legacy-gid"); + assert_eq!(legacy.uri, "https://legacy.example/file"); + assert_eq!(legacy.uris, vec!["https://legacy.example/file".to_owned()]); + assert_eq!(legacy.target_path, PathBuf::from("D:/downloads/legacy.bin")); + assert_eq!(legacy.metadata_path, None); + assert_eq!(legacy.metadata, None); + + let v2 = loaded.entries.get(1).expect("v2 entry should be present"); + assert_eq!(v2.gid, "gid-v2"); + assert_eq!( + v2.uris, + vec![ + "https://a.example/file".to_owned(), + "https://b.example/file,with-comma".to_owned(), + ] + ); + assert_eq!(v2.uri, "https://a.example/file"); + assert_eq!(v2.target_path, PathBuf::from("D:/downloads/v2.bin")); + assert_eq!( + v2.metadata_path, + Some(PathBuf::from("D:/downloads/v2.meta")) + ); + assert_eq!( + v2.metadata + .as_ref() + .and_then(|kv| kv.get("k=1")) + .map(String::as_str), + Some("v;1") + ); + + let _ = fs::remove_file(path); + } +} diff --git a/crates/aria2-rust-pro-storage/src/store.rs b/crates/aria2-rust-pro-storage/src/store.rs new file mode 100644 index 0000000..4cf52eb --- /dev/null +++ b/crates/aria2-rust-pro-storage/src/store.rs @@ -0,0 +1,427 @@ +use std::{fs, io, path::PathBuf}; + +use crate::{ + control::{ + ControlMetadata, decode_control_metadata, encode_control_metadata, read_aria2_control_file, + write_aria2_control_file, + }, + model::{PieceIndex, PieceMap, PieceState}, + resume::{ResumeData, ResumeStore}, + session::{Aria2MetadataState, SessionFile, load_session_file, save_session_file}, +}; + +/// Persists control metadata by download gid. +pub trait ControlStore { + /// Concrete error type returned by the store implementation. + type Error; + + /// Loads persisted control metadata for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when control metadata cannot be read. + fn load_control(&self, gid: &str) -> Result, Self::Error>; + /// Persists control metadata for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when control metadata cannot be written. + fn save_control(&self, gid: &str, value: &ControlMetadata) -> Result<(), Self::Error>; + /// Deletes control metadata for a download gid. + /// + /// # Errors + /// + /// Returns the store-specific error when control metadata cannot be removed. + fn delete_control(&self, gid: &str) -> Result<(), Self::Error>; +} + +/// Persists the session file. +pub trait SessionStore { + /// Concrete error type returned by the store implementation. + type Error; + + /// Loads the persisted session file. + /// + /// # Errors + /// + /// Returns the store-specific error when the session file cannot be read. + fn load_session(&self) -> Result; + /// Persists the session file. + /// + /// # Errors + /// + /// Returns the store-specific error when the session file cannot be written. + fn save_session(&self, session: &SessionFile) -> Result<(), Self::Error>; +} + +/// Persists metadata and resume state keyed by gid. +pub trait MetadataStore { + /// Concrete error type returned by the store implementation. + type Error; + + /// Loads persisted metadata state for a gid. + /// + /// # Errors + /// + /// Returns the store-specific error when metadata state cannot be read. + fn load_metadata_state(&self, gid: &str) -> Result, Self::Error>; + /// Persists metadata state for a gid. + /// + /// # Errors + /// + /// Returns the store-specific error when metadata state cannot be written. + fn save_metadata_state(&self, state: &Aria2MetadataState) -> Result<(), Self::Error>; + /// Persists resume data through the metadata-oriented store surface. + /// + /// # Errors + /// + /// Returns the store-specific error when resume data cannot be written. + fn save_resume_data(&self, resume: &ResumeData) -> Result<(), Self::Error>; +} + +/// Error type used by the local filesystem-backed store. +#[derive(Debug)] +pub enum LocalStoreError { + /// Raw filesystem I/O failure. + Io(io::Error), + /// Format or decoding failure. + Parse(String), +} + +impl From for LocalStoreError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +/// Local filesystem-backed implementation of the storage traits. +#[derive(Debug)] +pub struct LocalFileStore { + /// Root directory that contains the store layout. + root: PathBuf, +} + +impl LocalFileStore { + /// Creates a store rooted at the provided directory. + #[must_use] + pub const fn new(root: PathBuf) -> Self { + Self { root } + } + + /// Creates the on-disk directory layout used by the local store. + /// + /// # Errors + /// + /// Returns [`LocalStoreError`] when any required directory cannot be created. + pub fn ensure_layout(&self) -> Result<(), LocalStoreError> { + fs::create_dir_all(self.controls_dir())?; + fs::create_dir_all(self.metadata_dir())?; + fs::create_dir_all(self.resume_dir())?; + if let Some(parent) = self.session_path().parent() { + fs::create_dir_all(parent)?; + } + Ok(()) + } + + /// Returns the control-file directory. + fn controls_dir(&self) -> PathBuf { + self.root.join("control") + } + + /// Returns the metadata directory. + fn metadata_dir(&self) -> PathBuf { + self.root.join("metadata") + } + + /// Returns the resume directory. + fn resume_dir(&self) -> PathBuf { + self.root.join("resume") + } + + /// Returns the persisted session file path. + fn session_path(&self) -> PathBuf { + self.root.join("session").join("session.txt") + } + + /// Returns the control-file path for one gid. + fn control_path(&self, gid: &str) -> PathBuf { + self.controls_dir().join(format!("{gid}.aria2")) + } + + /// Returns the metadata path for one gid. + fn metadata_path(&self, gid: &str) -> PathBuf { + self.metadata_dir().join(format!("{gid}.meta")) + } + + /// Returns the resume-file path for one gid. + fn resume_path(&self, gid: &str) -> PathBuf { + self.resume_dir().join(format!("{gid}.resume")) + } +} + +impl ControlStore for LocalFileStore { + type Error = LocalStoreError; + fn load_control(&self, gid: &str) -> Result, Self::Error> { + let path = self.control_path(gid); + if !path.exists() { + return Ok(None); + } + read_aria2_control_file(&path) + .map(Some) + .map_err(|e| LocalStoreError::Parse(e.to_string())) + } + fn save_control(&self, gid: &str, value: &ControlMetadata) -> Result<(), Self::Error> { + self.ensure_layout()?; + write_aria2_control_file(&self.control_path(gid), value) + .map_err(|e| LocalStoreError::Parse(e.to_string())) + } + fn delete_control(&self, gid: &str) -> Result<(), Self::Error> { + let path = self.control_path(gid); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) + } +} + +impl SessionStore for LocalFileStore { + type Error = LocalStoreError; + fn load_session(&self) -> Result { + let path = self.session_path(); + if !path.exists() { + return Ok(SessionFile::default()); + } + load_session_file(&path).map_err(LocalStoreError::Io) + } + fn save_session(&self, session: &SessionFile) -> Result<(), Self::Error> { + self.ensure_layout()?; + save_session_file(&self.session_path(), session).map_err(LocalStoreError::Io) + } +} + +impl MetadataStore for LocalFileStore { + type Error = LocalStoreError; + fn load_metadata_state(&self, gid: &str) -> Result, Self::Error> { + let path = self.metadata_path(gid); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(path)?; + let mut kv = std::collections::BTreeMap::new(); + for line in content.lines() { + let mut parts = line.splitn(2, '='); + let k = parts.next().unwrap_or_default(); + let v = parts.next().unwrap_or_default(); + if !k.is_empty() { + kv.insert(k.to_owned(), v.to_owned()); + } + } + Ok(Some(Aria2MetadataState { + gid: gid.to_owned(), + kv, + })) + } + fn save_metadata_state(&self, state: &Aria2MetadataState) -> Result<(), Self::Error> { + self.ensure_layout()?; + let body = state + .kv + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("\n"); + fs::write(self.metadata_path(&state.gid), body)?; + Ok(()) + } + fn save_resume_data(&self, resume: &ResumeData) -> Result<(), Self::Error> { + ::save(self, resume) + } +} + +impl ResumeStore for LocalFileStore { + type Error = LocalStoreError; + fn load(&self, gid: &str) -> Result, Self::Error> { + let path = self.resume_path(gid); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(path)?; + let mut download_path = None; + let mut verified = Vec::new(); + let mut inflight = Vec::new(); + let mut failed = Vec::new(); + let mut metadata_blob = String::new(); + let mut in_metadata = false; + for line in content.lines() { + if line == "metadata<<" { + in_metadata = true; + continue; + } + if line == ">>metadata" { + in_metadata = false; + continue; + } + if in_metadata { + metadata_blob.push_str(line); + metadata_blob.push('\n'); + continue; + } + if let Some(v) = line.strip_prefix("download_path=") { + download_path = Some(PathBuf::from(v)); + } + if let Some(v) = line.strip_prefix("verified=") { + verified = parse_csv_u32(v)?; + } + if let Some(v) = line.strip_prefix("inflight=") { + inflight = parse_csv_u32(v)?; + } + if let Some(v) = line.strip_prefix("failed=") { + failed = parse_csv_u32(v)?; + } + } + let mut piece_map = PieceMap::default(); + for idx in verified { + piece_map.set_state(PieceIndex(idx), PieceState::Verified); + } + for idx in inflight { + piece_map.set_state(PieceIndex(idx), PieceState::InFlight); + } + for idx in failed { + piece_map.set_state(PieceIndex(idx), PieceState::Failed); + } + let metadata = if metadata_blob.trim().is_empty() { + None + } else { + Some( + decode_control_metadata(metadata_blob.trim_end()) + .map_err(|e| LocalStoreError::Parse(e.to_string()))?, + ) + }; + Ok(Some(ResumeData { + gid: gid.to_owned(), + download_path: download_path.unwrap_or_default(), + metadata, + piece_map, + })) + } + fn save(&self, resume: &ResumeData) -> Result<(), Self::Error> { + self.ensure_layout()?; + let mut lines = Vec::new(); + lines.push(format!( + "download_path={}", + resume.download_path.to_string_lossy() + )); + lines.push(format!( + "verified={}", + join_piece_indexes(&resume.piece_map, PieceState::Verified) + )); + lines.push(format!( + "inflight={}", + join_piece_indexes(&resume.piece_map, PieceState::InFlight) + )); + lines.push(format!( + "failed={}", + join_piece_indexes(&resume.piece_map, PieceState::Failed) + )); + if let Some(metadata) = &resume.metadata { + lines.push("metadata<<".to_owned()); + lines.push(encode_control_metadata(metadata)); + lines.push(">>metadata".to_owned()); + } + fs::write(self.resume_path(&resume.gid), lines.join("\n"))?; + Ok(()) + } + fn remove(&self, gid: &str) -> Result<(), Self::Error> { + let path = self.resume_path(gid); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) + } +} + +/// Parses a comma-separated list of piece indexes. +fn parse_csv_u32(raw: &str) -> Result, LocalStoreError> { + if raw.trim().is_empty() { + return Ok(Vec::new()); + } + raw.split(',') + .map(|s| { + s.parse::() + .map_err(|_| LocalStoreError::Parse(format!("invalid piece index: {s}"))) + }) + .collect() +} + +/// Joins piece indexes in the provided state into a comma-separated string. +fn join_piece_indexes(piece_map: &PieceMap, target: PieceState) -> String { + piece_map + .iter() + .filter(|(_, state)| **state == target) + .map(|(idx, _)| idx.0.to_string()) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::session::SessionFileEntry; + + fn temp_root() -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + std::env::temp_dir().join(format!("aria2-rust-pro-storage-test-{millis}")) + } + + #[test] + fn local_store_session_roundtrip() { + let root = temp_root(); + let store = LocalFileStore::new(root.clone()); + let mut metadata = BTreeMap::new(); + metadata.insert("bt.name".to_owned(), "ubuntu".to_owned()); + let session = SessionFile { + entries: vec![SessionFileEntry { + gid: "gid-1".to_owned(), + uri: "https://mirror-1.example/file.iso".to_owned(), + uris: vec![ + "https://mirror-1.example/file.iso".to_owned(), + "https://mirror-2.example/file.iso".to_owned(), + ], + target_path: PathBuf::from("D:/downloads/file.iso"), + metadata_path: Some(PathBuf::from("D:/downloads/file.meta")), + metadata: Some(metadata), + }], + }; + store.save_session(&session).unwrap(); + let loaded = store.load_session().unwrap(); + assert_eq!(loaded, session); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_store_resume_roundtrip() { + let root = temp_root(); + let store = LocalFileStore::new(root.clone()); + let mut piece_map = PieceMap::default(); + piece_map.set_state(PieceIndex(0), PieceState::Verified); + piece_map.set_state(PieceIndex(1), PieceState::InFlight); + piece_map.set_state(PieceIndex(2), PieceState::Failed); + let resume = ResumeData { + gid: "gid-resume-1".to_owned(), + download_path: PathBuf::from("D:/downloads/file.iso"), + metadata: None, + piece_map, + }; + store.save(&resume).unwrap(); + let loaded = store.load(&resume.gid).unwrap().unwrap(); + assert_eq!(loaded, resume); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/aria2-rust-pro-tests/Cargo.toml b/crates/aria2-rust-pro-tests/Cargo.toml new file mode 100644 index 0000000..c8d617d --- /dev/null +++ b/crates/aria2-rust-pro-tests/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "aria2-rust-pro-tests" +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_tests" +path = "src/lib.rs" + +[dev-dependencies] +aria2-rust-pro-cli.workspace = true +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 +criterion = "0.5.1" + +[[bench]] +name = "rpc_pressure" +harness = false + +[lints] +workspace = true diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure.rs new file mode 100644 index 0000000..60a0560 --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure.rs @@ -0,0 +1,63 @@ +//! Criterion pressure benches for shared-runtime RPC and transfer paths. +//! +//! The benchmark suite keeps intentionally explicit arithmetic and indexing so the +//! expected request/throughput math remains easy to audit when performance +//! regressions are investigated. + +#![forbid(unsafe_code)] +#![doc(hidden)] +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::default_trait_access, + clippy::indexing_slicing, + clippy::integer_division, + clippy::too_many_lines, + reason = "pressure benches keep explicit scenario math and fixture setup for auditability" +)] + +use aria2_rust_pro_compat as _; +use aria2_rust_pro_storage as _; +use aria2_rust_pro_tests as _; +use criterion::{criterion_group, criterion_main}; + +#[path = "rpc_pressure/bt_visibility.rs"] +mod bt_visibility; +#[path = "rpc_pressure/live_http_transfer.rs"] +mod live_http_transfer; +#[path = "rpc_pressure/rpc_runtime_pressure.rs"] +mod rpc_runtime_pressure; +#[path = "rpc_pressure/runtime_engine_pressure.rs"] +mod runtime_engine_pressure; +#[path = "rpc_pressure/support.rs"] +mod support; + +use bt_visibility::bench_bt_visibility_pressure; +use live_http_transfer::{ + bench_live_http_multi_download_contention_pressure, + bench_live_http_shared_runtime_multi_download_pressure, + bench_live_http_transfer_contention_pressure, +}; +use rpc_runtime_pressure::{ + bench_mixed_rpc_pressure, bench_resource_limit_pressure, + bench_shared_runtime_fairness_pressure, bench_tell_status_pressure, +}; +use runtime_engine_pressure::{ + bench_runtime_snapshot_pressure, bench_scheduler_backpressure_pressure, +}; + +criterion_group!( + pressure_benches, + bench_tell_status_pressure, + bench_mixed_rpc_pressure, + bench_runtime_snapshot_pressure, + bench_resource_limit_pressure, + bench_shared_runtime_fairness_pressure, + bench_bt_visibility_pressure, + bench_scheduler_backpressure_pressure, + bench_live_http_transfer_contention_pressure, + bench_live_http_multi_download_contention_pressure, + bench_live_http_shared_runtime_multi_download_pressure +); +criterion_main!(pressure_benches); diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure/bt_visibility.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure/bt_visibility.rs new file mode 100644 index 0000000..3925f0b --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure/bt_visibility.rs @@ -0,0 +1,202 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "criterion bench entry points are re-exported only to the private bench root module" +)] + +use super::support::{ + BT_TORRENT_FIXTURE, BenchmarkId, Criterion, InProcessRpcDispatcher, RpcMethod, RpcValue, + RuntimeConfig, Throughput, TorrentPeerModel, TrackerPeerListModel, TrackerResponseModel, + rpc_request, +}; + +#[derive(Clone, Copy, Debug)] +struct BtVisibilityPressureScenario { + task_count: usize, + rounds: usize, +} + +fn seed_bt_visibility_dispatcher( + scenario: BtVisibilityPressureScenario, +) -> (InProcessRpcDispatcher, Vec) { + let runtime = RuntimeConfig { + allow_jsonrpc: true, + allow_xmlrpc: true, + split: 4, + max_connections_per_server: 4, + max_connection_per_server: 4, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }; + let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime); + let mut gids = Vec::with_capacity(scenario.task_count); + for index in 0..scenario.task_count { + let add_response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())], + )); + let gid = match add_response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result in visibility seed: {other:?}"), + }; + dispatcher + .apply_tracker_announce_result( + &gid, + &TrackerResponseModel { + peers: TrackerPeerListModel { + interval_sec: 900, + peers: vec![TorrentPeerModel { + ip: format!("198.51.100.{}", (index % 200) + 1), + port: 51413 + (index as u16 % 32), + peer_id: None, + client_name: None, + interested: false, + choked: false, + }], + min_interval_sec: None, + tracker_id: Some(format!("bench-{index}")), + }, + scrape: None, + }, + ) + .expect("tracker seed should populate visible bt peers"); + dispatcher + .apply_bt_runtime_tick( + &gid, + 8_192 + (index as u64 % 4) * 2_048, + if index % 3 == 0 { 4_096 } else { 0 }, + 512 + index as u64, + 128 + index as u64, + if index % 3 == 0 { 5 } else { 0 }, + if index % 3 == 0 { 5 } else { 0 }, + index % 3 == 0, + Some(2 + (index % 6) as u32), + ) + .expect("runtime tick should seed visible bt progress"); + if index % 3 == 0 { + dispatcher + .set_bt_seeding_state(&gid, true, Some(1_000 + index as u64)) + .expect("seeded torrents should enter seeding"); + dispatcher + .tick_bt_runtime_clock(&gid, 1_010 + index as u64, true) + .expect("seeded torrents should advance share clocks"); + } + gids.push(gid); + } + (dispatcher, gids) +} + +fn run_bt_visibility_pressure( + dispatcher: &mut InProcessRpcDispatcher, + gids: &[String], + scenario: BtVisibilityPressureScenario, +) -> usize { + let mut calls = 0_usize; + for round in 0..scenario.rounds { + for (index, gid) in gids.iter().enumerate() { + dispatcher + .apply_bt_runtime_tick( + gid, + if round % 2 == 0 { 512 } else { 0 }, + if round % 2 == 1 { 256 } else { 0 }, + 1_024 + round as u64 + index as u64, + 256 + round as u64 + index as u64, + u64::from(index % 3 == 0), + u64::from(index % 3 == 0), + index % 3 == 0, + Some(2 + ((round + index) % 6) as u32), + ) + .expect("pressure tick should keep bt runtime visible"); + + let tell_status = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match tell_status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(payload.contains_key("announceList")); + assert!(payload.contains_key("bitfield")); + assert!(payload.contains_key("shareRatio")); + assert!(payload.contains_key("shareTime")); + assert!(payload.contains_key("files")); + assert!(payload.contains_key("seeder")); + } + other => panic!("unexpected tellStatus payload in bt visibility bench: {other:?}"), + } + calls += 1; + + let get_files = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + )); + match get_files.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(file)) => { + assert!(file.contains_key("selected")); + assert!(file.contains_key("completedLength")); + assert!(file.contains_key("bitfield")); + } + other => { + panic!("unexpected getFiles payload in bt visibility bench: {other:?}") + } + }, + other => panic!("unexpected getFiles result in bt visibility bench: {other:?}"), + } + calls += 1; + + let get_peers = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + )); + match get_peers.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(peer)) => { + assert!(peer.contains_key("ip")); + assert!(peer.contains_key("port")); + assert!(peer.contains_key("peerChoking")); + } + other => { + panic!("unexpected getPeers payload in bt visibility bench: {other:?}") + } + }, + other => panic!("unexpected getPeers result in bt visibility bench: {other:?}"), + } + calls += 1; + } + } + calls +} + +pub(super) fn bench_bt_visibility_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("bt_visibility_pressure"); + for scenario in [ + BtVisibilityPressureScenario { + task_count: 32, + rounds: 4, + }, + BtVisibilityPressureScenario { + task_count: 64, + rounds: 4, + }, + ] { + group.throughput(Throughput::Elements( + (scenario.task_count * scenario.rounds * 3) as u64, + )); + group.bench_with_input( + BenchmarkId::new("bt_visibility", scenario.task_count), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_bt_visibility_dispatcher(scenario), + |(mut dispatcher, gids)| { + let calls = run_bt_visibility_pressure(&mut dispatcher, &gids, scenario); + assert_eq!(calls, scenario.task_count * scenario.rounds * 3); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure/live_http_transfer.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure/live_http_transfer.rs new file mode 100644 index 0000000..edc33de --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure/live_http_transfer.rs @@ -0,0 +1,602 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "criterion bench entry points are re-exported only to the private bench root module" +)] + +use super::support::{ + Arc, AtomicBool, BTreeMap, BenchmarkId, ConnectorBackedDownloader, Criterion, Duration, + Instant, Invocation, Mutex, Ordering, PathBuf, Read, ReqwestHttpConnector, SocketAddr, + SystemTime, TcpListener, TcpStream, Throughput, UNIX_EPOCH, Write, + execute_runtime_with_downloader, fs, thread, +}; + +const LIVE_TRANSFER_SPLIT: usize = 4; +const LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER: usize = 4; + +#[derive(Clone, Copy, Debug)] +struct LiveTransferScenario { + label: &'static str, + total_length: usize, + piece_length: usize, + overall_download_limit: Option, + disk_cache_bytes: Option, + response_delay_ms: u64, +} + +#[derive(Clone, Copy, Debug)] +struct LiveTransferContentionScenario { + label: &'static str, + download_count: usize, + total_length: usize, + piece_length: usize, + overall_download_limit: Option, + disk_cache_bytes: Option, + response_delay_ms: u64, +} + +#[derive(Clone, Debug, Default)] +struct SegmentRequestMetrics { + total_requests: usize, + requests_by_path: BTreeMap, +} + +struct TempConfigFile { + path: PathBuf, +} + +impl TempConfigFile { + fn new(contents: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("aria2-rust-pro-{unique}.conf")); + fs::write(&path, contents).expect("benchmark config should write"); + Self { path } + } + + const fn path(&self) -> &PathBuf { + &self.path + } +} + +impl Drop for TempConfigFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +struct LocalHttpSegmentServer { + base_url: String, + metrics: Arc>, + stop: Arc, + handle: Option>, + listen_address: SocketAddr, +} + +impl LocalHttpSegmentServer { + fn spawn(scenario: LiveTransferScenario) -> Self { + let expected_requests_per_download = scenario.total_length.div_ceil(scenario.piece_length); + Self::spawn_many(scenario, 1, expected_requests_per_download) + } + + fn spawn_many( + scenario: LiveTransferScenario, + _download_count: usize, + _expected_requests_per_download: usize, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("loopback listener should bind"); + let addr = listener + .local_addr() + .expect("loopback listener should report local addr"); + let piece_length = scenario.piece_length; + let total_length = scenario.total_length; + let response_delay = Duration::from_millis(scenario.response_delay_ms); + let payload_bytes = Arc::<[u8]>::from(vec![b'x'; total_length]); + let metrics = Arc::new(Mutex::new(SegmentRequestMetrics::default())); + let metrics_for_thread = Arc::clone(&metrics); + let stop = Arc::new(AtomicBool::new(false)); + let stop_for_thread = Arc::clone(&stop); + let handle = thread::spawn(move || { + loop { + let (stream, _) = listener + .accept() + .unwrap_or_else(|error| panic!("bench client should connect: {error}")); + if stop_for_thread.load(Ordering::Relaxed) { + break; + } + let payload_bytes = Arc::clone(&payload_bytes); + let metrics = Arc::clone(&metrics_for_thread); + thread::spawn(move || { + handle_loopback_segment_request( + stream, + &payload_bytes, + &metrics, + piece_length, + total_length, + response_delay, + ); + }); + } + }); + Self { + base_url: format!("http://{addr}"), + metrics, + stop, + handle: Some(handle), + listen_address: addr, + } + } + + fn url_for(&self, path: &str) -> String { + format!("{}/{}", self.base_url, path.trim_start_matches('/')) + } + + fn snapshot_metrics(&self) -> SegmentRequestMetrics { + self.metrics + .lock() + .expect("segment request metrics mutex should not poison") + .clone() + } +} + +impl Drop for LocalHttpSegmentServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(self.listen_address); + if let Some(handle) = self.handle.take() { + handle.join().expect("loopback server thread should join"); + } + } +} + +fn handle_loopback_segment_request( + mut stream: TcpStream, + payload_bytes: &Arc<[u8]>, + metrics: &Arc>, + piece_length: usize, + total_length: usize, + response_delay: Duration, +) { + stream + .set_nonblocking(false) + .expect("accepted bench socket should switch back to blocking mode"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("request should read"); + let request_text = String::from_utf8_lossy(&request[..read]); + let request_path = parse_request_path(&request_text).to_owned(); + let (start, end_inclusive) = parse_requested_range(&request_text, piece_length, total_length); + let len = end_inclusive.saturating_sub(start).saturating_add(1); + let body = payload_bytes.get(start..=end_inclusive).unwrap_or(&[]); + { + let mut metrics = metrics + .lock() + .expect("segment request metrics mutex should not poison"); + metrics.total_requests += 1; + *metrics.requests_by_path.entry(request_path).or_default() += 1; + } + thread::sleep(response_delay); + let response_head = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {len}\r\nContent-Range: bytes {start}-{end_inclusive}/{total_length}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response_head.as_bytes()) + .expect("response head should write"); + stream.write_all(body).expect("response body should write"); +} + +fn parse_request_path(request_text: &str) -> &str { + request_text + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/payload.bin") +} + +fn parse_requested_range( + request_text: &str, + piece_length: usize, + total_length: usize, +) -> (usize, usize) { + let requested = request_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if !name.trim().eq_ignore_ascii_case("range") { + return None; + } + value.trim().strip_prefix("bytes=") + }) + .and_then(|value| value.split_once('-')) + .map(|(start, end)| { + let start = start.parse::().expect("range start should parse"); + let end = end.parse::().expect("range end should parse"); + (start, end.min(total_length.saturating_sub(1))) + }); + + requested.unwrap_or_else(|| { + let start = 0usize; + let end = piece_length + .saturating_sub(1) + .min(total_length.saturating_sub(1)); + (start, end) + }) +} + +fn build_live_transfer_config(scenario: LiveTransferScenario) -> TempConfigFile { + let mut lines = vec![ + format!("split={LIVE_TRANSFER_SPLIT}"), + format!("max-connection-per-server={LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER}"), + format!("min-split-size={}", scenario.piece_length), + format!("piece-length={}", scenario.piece_length), + ]; + if let Some(limit) = scenario.overall_download_limit { + lines.push(format!("max-overall-download-limit={limit}")); + } + if let Some(disk_cache_bytes) = scenario.disk_cache_bytes { + lines.push(format!("disk-cache={disk_cache_bytes}")); + } + TempConfigFile::new(&lines.join("\n")) +} + +fn min_expected_live_requests_per_download(scenario: LiveTransferContentionScenario) -> usize { + let total_segments = scenario.total_length.div_ceil(scenario.piece_length); + if LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER >= 4 { + let probe_segments = if scenario.piece_length <= 64 * 1024 { + LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER.saturating_mul(2) + } else { + 2usize + }; + if total_segments <= probe_segments { + return 1; + } + + let remaining_segments = total_segments.saturating_sub(probe_segments); + let followup_floor = if remaining_segments <= 2 { + 1 + } else if remaining_segments <= 3 { + 2 + } else { + remaining_segments.min(LIVE_TRANSFER_SPLIT.saturating_sub(1)) + }; + return 1usize.saturating_add(followup_floor); + } + + let mut segmented_floor = 1usize.saturating_add( + total_segments + .saturating_sub(1) + .min(LIVE_TRANSFER_SPLIT.saturating_sub(1)), + ); + if segmented_floor > 3 && total_segments <= LIVE_TRANSFER_SPLIT { + segmented_floor = segmented_floor.saturating_sub(1); + } + total_segments.min(segmented_floor).max(1) +} + +fn request_budget_per_download(scenario: LiveTransferContentionScenario) -> usize { + min_expected_live_requests_per_download(scenario).saturating_mul(4) +} + +fn run_live_transfer_contention_scenario(scenario: LiveTransferScenario) -> usize { + let config = build_live_transfer_config(scenario); + let server = LocalHttpSegmentServer::spawn(scenario); + let connector = ReqwestHttpConnector::new().expect("reqwest connector should build"); + let downloader = ConnectorBackedDownloader::new(connector.clone(), connector); + let report = execute_runtime_with_downloader( + Invocation::Run { + config_path: Some(config.path().clone()), + uris: vec![server.url_for("payload.bin")], + }, + &downloader, + ) + .expect("live transfer benchmark should complete"); + assert_eq!(report.completed_download_count, 1); + assert_eq!(report.first_status.as_deref(), Some("complete")); + assert_eq!( + report.first_completed_length, + Some(scenario.total_length as u64) + ); + scenario.total_length +} + +fn run_live_transfer_fairness_contention_scenario( + scenario: LiveTransferContentionScenario, +) -> usize { + let single_transfer = LiveTransferScenario { + label: scenario.label, + total_length: scenario.total_length, + piece_length: scenario.piece_length, + overall_download_limit: scenario.overall_download_limit, + disk_cache_bytes: scenario.disk_cache_bytes, + response_delay_ms: scenario.response_delay_ms, + }; + let min_requests_per_download = min_expected_live_requests_per_download(scenario); + let request_budget_per_download = request_budget_per_download(scenario); + let server = LocalHttpSegmentServer::spawn_many( + single_transfer, + scenario.download_count, + request_budget_per_download, + ); + let worker_handles = (0..scenario.download_count) + .map(|_| build_live_transfer_config(single_transfer)) + .enumerate() + .map(|(index, config)| { + let config_path = config.path().clone(); + let uri = server.url_for(&format!("payload-{index}.bin")); + thread::spawn(move || { + let _config_guard = config; + let connector = + ReqwestHttpConnector::new().expect("reqwest connector should build"); + let downloader = ConnectorBackedDownloader::new(connector.clone(), connector); + let started = Instant::now(); + let report = execute_runtime_with_downloader( + Invocation::Run { + config_path: Some(config_path), + uris: vec![uri], + }, + &downloader, + ) + .expect("parallel live transfer benchmark should complete"); + (started.elapsed(), report) + }) + }) + .collect::>(); + + let mut elapsed = Vec::with_capacity(scenario.download_count); + for worker in worker_handles { + let (duration, report) = worker + .join() + .expect("parallel live transfer worker thread should join"); + assert_eq!(report.completed_download_count, 1); + assert_eq!(report.first_status.as_deref(), Some("complete")); + assert_eq!( + report.first_completed_length, + Some(scenario.total_length as u64) + ); + elapsed.push(duration); + } + + let metrics = server.snapshot_metrics(); + assert_eq!(metrics.requests_by_path.len(), scenario.download_count); + assert!( + metrics.total_requests >= min_requests_per_download * scenario.download_count, + "parallel live transfer should keep every download progressing through segmented requests: observed total_requests={} floor_per_download={} download_count={}", + metrics.total_requests, + min_requests_per_download, + scenario.download_count + ); + for index in 0..scenario.download_count { + let path = format!("/payload-{index}.bin"); + let observed = metrics + .requests_by_path + .get(&path) + .copied() + .unwrap_or_default(); + assert!( + observed >= min_requests_per_download, + "each download should exercise the segmented path under contention: path={path} observed={observed} floor={min_requests_per_download}" + ); + } + + let min_elapsed = elapsed + .iter() + .min() + .copied() + .expect("at least one live contention run should exist"); + let max_elapsed = elapsed + .iter() + .max() + .copied() + .expect("at least one live contention run should exist"); + assert!(max_elapsed >= min_elapsed); + scenario.total_length * scenario.download_count +} + +fn run_live_shared_runtime_multi_download_scenario( + scenario: LiveTransferContentionScenario, +) -> usize { + let single_transfer = LiveTransferScenario { + label: scenario.label, + total_length: scenario.total_length, + piece_length: scenario.piece_length, + overall_download_limit: scenario.overall_download_limit, + disk_cache_bytes: scenario.disk_cache_bytes, + response_delay_ms: scenario.response_delay_ms, + }; + let config = build_live_transfer_config(single_transfer); + let min_requests_per_download = min_expected_live_requests_per_download(scenario); + let request_budget_per_download = request_budget_per_download(scenario); + let server = LocalHttpSegmentServer::spawn_many( + single_transfer, + scenario.download_count, + request_budget_per_download, + ); + let connector = ReqwestHttpConnector::new().expect("reqwest connector should build"); + let downloader = ConnectorBackedDownloader::new(connector.clone(), connector); + let report = execute_runtime_with_downloader( + Invocation::Run { + config_path: Some(config.path().clone()), + uris: (0..scenario.download_count) + .map(|index| server.url_for(&format!("shared-runtime-{index}.bin"))) + .collect(), + }, + &downloader, + ) + .expect("shared-runtime live transfer benchmark should complete"); + + assert_eq!(report.accepted_uri_count, scenario.download_count); + assert_eq!(report.tracked_download_count, scenario.download_count); + assert_eq!(report.completed_download_count, scenario.download_count); + assert_eq!(report.first_status.as_deref(), Some("complete")); + assert_eq!( + report.first_completed_length, + Some(scenario.total_length as u64) + ); + + let metrics = server.snapshot_metrics(); + assert_eq!(metrics.requests_by_path.len(), scenario.download_count); + assert!( + metrics.total_requests >= min_requests_per_download * scenario.download_count, + "shared-runtime live transfer should keep every registered download progressing: observed total_requests={} floor_per_download={} download_count={}", + metrics.total_requests, + min_requests_per_download, + scenario.download_count + ); + for index in 0..scenario.download_count { + let path = format!("/shared-runtime-{index}.bin"); + let observed = metrics + .requests_by_path + .get(&path) + .copied() + .unwrap_or_default(); + assert!( + observed >= min_requests_per_download, + "shared-runtime live transfer should fully progress each registered download: path={path} observed={observed} floor={min_requests_per_download}" + ); + } + + scenario.total_length * scenario.download_count +} + +pub(super) fn bench_live_http_transfer_contention_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("live_http_transfer_contention_pressure"); + for scenario in [ + LiveTransferScenario { + label: "loose_cap", + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: None, + disk_cache_bytes: None, + response_delay_ms: 8, + }, + LiveTransferScenario { + label: "tight_cap", + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: Some(4 * 1024), + disk_cache_bytes: None, + response_delay_ms: 8, + }, + ] { + group.throughput(Throughput::Bytes(scenario.total_length as u64)); + group.bench_with_input( + BenchmarkId::new("live_http_transfer", scenario.label), + &scenario, + |b, &scenario| { + b.iter_batched( + || scenario, + |scenario| { + let bytes = run_live_transfer_contention_scenario(scenario); + assert_eq!(bytes, scenario.total_length); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_live_http_multi_download_contention_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("live_http_multi_download_contention_pressure"); + for scenario in [ + LiveTransferContentionScenario { + label: "loose_cap", + download_count: 3, + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: None, + disk_cache_bytes: None, + response_delay_ms: 8, + }, + LiveTransferContentionScenario { + label: "tight_cap", + download_count: 3, + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: Some(4 * 1024), + disk_cache_bytes: None, + response_delay_ms: 8, + }, + ] { + group.throughput(Throughput::Bytes( + (scenario.total_length * scenario.download_count) as u64, + )); + group.bench_with_input( + BenchmarkId::new("multi_live_http_transfer", scenario.label), + &scenario, + |b, &scenario| { + b.iter_batched( + || scenario, + |scenario| { + let bytes = run_live_transfer_fairness_contention_scenario(scenario); + assert_eq!(bytes, scenario.total_length * scenario.download_count); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_live_http_shared_runtime_multi_download_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("live_http_shared_runtime_multi_download_pressure"); + for scenario in [ + LiveTransferContentionScenario { + label: "loose_cap", + download_count: 3, + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: None, + disk_cache_bytes: None, + response_delay_ms: 8, + }, + LiveTransferContentionScenario { + label: "tight_cap", + download_count: 3, + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: Some(4 * 1024), + disk_cache_bytes: None, + response_delay_ms: 8, + }, + LiveTransferContentionScenario { + label: "tight_cap_6way", + download_count: 6, + total_length: 16 * 1024, + piece_length: 4 * 1024, + overall_download_limit: Some(4 * 1024), + disk_cache_bytes: None, + response_delay_ms: 8, + }, + LiveTransferContentionScenario { + label: "cache_pressure_6way_256k", + download_count: 6, + total_length: 256 * 1024, + piece_length: 32 * 1024, + overall_download_limit: Some(64 * 1024), + disk_cache_bytes: Some(64 * 1024), + response_delay_ms: 2, + }, + ] { + group.throughput(Throughput::Bytes( + (scenario.total_length * scenario.download_count) as u64, + )); + group.bench_with_input( + BenchmarkId::new("shared_runtime_live_http_transfer", scenario.label), + &scenario, + |b, &scenario| { + b.iter_batched( + || scenario, + |scenario| { + let bytes = run_live_shared_runtime_multi_download_scenario(scenario); + assert_eq!(bytes, scenario.total_length * scenario.download_count); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure/rpc_runtime_pressure.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure/rpc_runtime_pressure.rs new file mode 100644 index 0000000..cbcc947 --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure/rpc_runtime_pressure.rs @@ -0,0 +1,667 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "criterion bench entry points are re-exported only to the private bench root module" +)] + +use super::support::{ + BTreeMap, BenchmarkId, Criterion, InProcessRpcDispatcher, RpcMethod, RpcValue, RuntimeConfig, + Throughput, rpc_request, +}; + +#[derive(Clone, Copy, Debug)] +struct PressureScenario { + task_count: usize, + rounds: usize, + split: usize, + max_connections_per_server: usize, +} + +impl PressureScenario { + fn runtime(self) -> RuntimeConfig { + RuntimeConfig { + split: self.split, + max_connections_per_server: self.max_connections_per_server, + max_connection_per_server: self.max_connections_per_server, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + } + } +} + +#[derive(Clone, Copy, Debug)] +struct ResourceLimitScenario { + task_count: usize, + rounds: usize, + global_download_limit: u64, + per_download_limit: u64, + global_upload_limit: u64, + per_upload_limit: u64, + disk_cache_bytes: u64, +} + +#[derive(Clone, Copy, Debug)] +struct SharedRuntimeFairnessScenario { + label: &'static str, + task_count: usize, + rounds: usize, + global_download_limit: u64, + default_per_download_limit: u64, + constrained_per_download_limit: u64, + global_upload_limit: u64, + default_per_upload_limit: u64, + constrained_per_upload_limit: u64, +} + +fn seed_pressure_dispatcher(scenario: PressureScenario) -> (InProcessRpcDispatcher, Vec) { + let mut dispatcher = InProcessRpcDispatcher::with_runtime(scenario.runtime()); + let mut gids = Vec::with_capacity(scenario.task_count); + for i in 0..scenario.task_count { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=bench-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 40_001 + ); + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(magnet)], + )); + match response.result { + Some(RpcValue::String(gid)) => gids.push(gid), + other => panic!("unexpected addUri result in bench setup: {other:?}"), + } + } + + for (index, gid) in gids.iter().enumerate() { + dispatcher + .apply_bt_runtime_tick( + gid, + 1024 + (index % 4) as u64 * 256, + 256 + (index % 3) as u64 * 64, + 300 + index as u64 % 80, + 120 + index as u64 % 40, + 1, + 1, + false, + Some(4 + (index % 4) as u32), + ) + .expect("bench setup runtime tick should succeed"); + } + + (dispatcher, gids) +} + +fn seed_limited_dispatcher( + scenario: ResourceLimitScenario, +) -> (InProcessRpcDispatcher, Vec) { + let base = PressureScenario { + task_count: scenario.task_count, + rounds: scenario.rounds, + split: 6, + max_connections_per_server: 6, + }; + let (mut dispatcher, gids) = seed_pressure_dispatcher(base); + let change_global = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-overall-download-limit".to_owned(), + RpcValue::String(scenario.global_download_limit.to_string()), + ), + ( + "max-overall-upload-limit".to_owned(), + RpcValue::String(scenario.global_upload_limit.to_string()), + ), + ( + "disk-cache".to_owned(), + RpcValue::String(scenario.disk_cache_bytes.to_string()), + ), + ]))], + )); + assert!( + change_global.error.is_none(), + "changeGlobalOption should succeed in benchmark setup" + ); + + for gid in &gids { + let change_option = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([ + ( + "max-download-limit".to_owned(), + RpcValue::String(scenario.per_download_limit.to_string()), + ), + ( + "max-upload-limit".to_owned(), + RpcValue::String(scenario.per_upload_limit.to_string()), + ), + ])), + ], + )); + assert!( + change_option.error.is_none(), + "changeOption should succeed in benchmark setup" + ); + } + + (dispatcher, gids) +} + +fn seed_shared_runtime_fairness_dispatcher( + scenario: SharedRuntimeFairnessScenario, +) -> (InProcessRpcDispatcher, Vec) { + let base = PressureScenario { + task_count: scenario.task_count, + rounds: scenario.rounds, + split: 6, + max_connections_per_server: 6, + }; + let (mut dispatcher, gids) = seed_pressure_dispatcher(base); + let change_global = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-overall-download-limit".to_owned(), + RpcValue::String(scenario.global_download_limit.to_string()), + ), + ( + "max-overall-upload-limit".to_owned(), + RpcValue::String(scenario.global_upload_limit.to_string()), + ), + ]))], + )); + assert!( + change_global.error.is_none(), + "changeGlobalOption should succeed in shared-runtime fairness setup" + ); + + for (index, gid) in gids.iter().enumerate() { + let (download_limit, upload_limit) = if index == 0 { + ( + scenario.constrained_per_download_limit, + scenario.constrained_per_upload_limit, + ) + } else { + ( + scenario.default_per_download_limit, + scenario.default_per_upload_limit, + ) + }; + let change_option = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([ + ( + "max-download-limit".to_owned(), + RpcValue::String(download_limit.to_string()), + ), + ( + "max-upload-limit".to_owned(), + RpcValue::String(upload_limit.to_string()), + ), + ])), + ], + )); + assert!( + change_option.error.is_none(), + "changeOption should succeed in shared-runtime fairness setup" + ); + } + + (dispatcher, gids) +} + +fn run_tell_status_pressure( + dispatcher: &mut InProcessRpcDispatcher, + gids: &[String], + rounds: usize, +) -> usize { + let mut calls = 0usize; + for round in 0..rounds { + let gid = &gids[round % gids.len()]; + dispatcher + .apply_bt_runtime_tick( + gid, + 0, + 0, + 400 + round as u64 * 10, + 160 + round as u64 * 5, + 0, + 0, + false, + Some(8), + ) + .expect("bench tellStatus churn should succeed"); + + for gid in gids { + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match response.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + calls += 1; + } + other => panic!("unexpected tellStatus payload in bench: {other:?}"), + } + } + } + calls +} + +fn run_resource_limit_pressure( + dispatcher: &mut InProcessRpcDispatcher, + gids: &[String], + scenario: ResourceLimitScenario, +) -> usize { + let mut calls = 0usize; + let expected_download_speed = (scenario.global_download_limit / scenario.task_count as u64) + .min(scenario.per_download_limit) + .max(1); + let expected_upload_speed = (scenario.global_upload_limit / scenario.task_count as u64) + .min(scenario.per_upload_limit) + .max(1); + + for round in 0..scenario.rounds { + for gid in gids { + dispatcher + .apply_bt_runtime_tick(gid, 64, 32, 5_000, 2_000, 1, 1, false, Some(6)) + .expect("bench limited runtime tick should succeed"); + } + + let gid = &gids[round % gids.len()]; + let tell_status = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match tell_status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String(expected_download_speed.to_string())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String(expected_upload_speed.to_string())) + ); + calls += 1; + } + other => panic!("unexpected tellStatus payload under resource caps: {other:?}"), + } + + let tell_global = + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())); + match tell_global.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String( + expected_download_speed + .saturating_mul(scenario.task_count as u64) + .to_string(), + )) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String( + expected_upload_speed + .saturating_mul(scenario.task_count as u64) + .to_string(), + )) + ); + calls += 1; + } + other => panic!("unexpected tellGlobalStat payload under resource caps: {other:?}"), + } + } + + calls +} + +fn run_mixed_rpc_pressure( + dispatcher: &mut InProcessRpcDispatcher, + gids: &[String], + rounds: usize, +) -> usize { + let mut calls = 0usize; + for round in 0..rounds { + let gid = &gids[round % gids.len()]; + dispatcher + .apply_bt_runtime_tick( + gid, + 128, + 64, + 500 + round as u64 * 15, + 180 + round as u64 * 6, + 1, + 1, + round + 1 >= rounds, + Some(6 + (round % 4) as u32), + ) + .expect("bench mixed churn should succeed"); + + let tell_status = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + assert!(matches!(tell_status.result, Some(RpcValue::Object(_)))); + calls += 1; + + let tell_active = + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellActive, Vec::new())); + assert!(matches!(tell_active.result, Some(RpcValue::Array(_)))); + calls += 1; + + let tell_global = + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())); + assert!(matches!(tell_global.result, Some(RpcValue::Object(_)))); + calls += 1; + } + calls +} + +fn run_shared_runtime_fairness_pressure( + dispatcher: &mut InProcessRpcDispatcher, + gids: &[String], + scenario: SharedRuntimeFairnessScenario, +) -> usize { + let mut calls = 0usize; + let initial_shared_download_speed = + (scenario.global_download_limit / scenario.task_count as u64).max(1); + let initial_shared_upload_speed = + (scenario.global_upload_limit / scenario.task_count as u64).max(1); + let constrained_initial_download_speed = initial_shared_download_speed + .min(scenario.constrained_per_download_limit) + .max(1); + let constrained_initial_upload_speed = initial_shared_upload_speed + .min(scenario.constrained_per_upload_limit) + .max(1); + let rebalanced_active_count = scenario.task_count.saturating_sub(1).max(1); + let rebalanced_download_speed = (scenario.global_download_limit + / rebalanced_active_count as u64) + .min(scenario.default_per_download_limit) + .max(1); + let rebalanced_upload_speed = (scenario.global_upload_limit / rebalanced_active_count as u64) + .min(scenario.default_per_upload_limit) + .max(1); + + for round in 0..scenario.rounds { + let active_slice = if round == 0 { gids } else { &gids[1..] }; + for gid in active_slice { + dispatcher + .apply_bt_runtime_tick(gid, 128, 64, 5_000, 2_000, 0, 0, false, Some(6)) + .expect("shared-runtime fairness tick should succeed"); + } + + for (index, gid) in gids.iter().enumerate() { + let tell_status = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + )); + match tell_status.result { + Some(RpcValue::Object(payload)) => { + if round > 0 && index == 0 { + assert_eq!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + } else { + let expected_download_speed = if index == 0 { + constrained_initial_download_speed + } else if round == 0 { + initial_shared_download_speed + .min(scenario.default_per_download_limit) + .max(1) + } else { + rebalanced_download_speed + }; + let expected_upload_speed = if index == 0 { + constrained_initial_upload_speed + } else if round == 0 { + initial_shared_upload_speed + .min(scenario.default_per_upload_limit) + .max(1) + } else { + rebalanced_upload_speed + }; + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String(expected_download_speed.to_string())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String(expected_upload_speed.to_string())) + ); + } + calls += 1; + } + other => panic!( + "unexpected tellStatus payload in shared-runtime fairness bench: {other:?}" + ), + } + } + + let tell_global = + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())); + match tell_global.result { + Some(RpcValue::Object(payload)) => { + let (expected_download_speed, expected_upload_speed) = if round == 0 { + ( + constrained_initial_download_speed.saturating_add( + initial_shared_download_speed + .min(scenario.default_per_download_limit) + .max(1) + .saturating_mul(rebalanced_active_count as u64), + ), + constrained_initial_upload_speed.saturating_add( + initial_shared_upload_speed + .min(scenario.default_per_upload_limit) + .max(1) + .saturating_mul(rebalanced_active_count as u64), + ), + ) + } else { + ( + constrained_initial_download_speed + .saturating_add( + rebalanced_download_speed + .saturating_mul(rebalanced_active_count as u64), + ) + .min(scenario.global_download_limit), + constrained_initial_upload_speed + .saturating_add( + rebalanced_upload_speed + .saturating_mul(rebalanced_active_count as u64), + ) + .min(scenario.global_upload_limit), + ) + }; + assert_eq!( + payload.get("downloadSpeed"), + Some(&RpcValue::String(expected_download_speed.to_string())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String(expected_upload_speed.to_string())) + ); + calls += 1; + } + other => panic!( + "unexpected tellGlobalStat payload in shared-runtime fairness bench: {other:?}" + ), + } + + if round == 0 { + dispatcher + .mark_complete(&gids[0]) + .expect("shared-runtime fairness benchmark should complete one gid"); + } + } + + calls +} + +pub(super) fn bench_tell_status_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("rpc_tell_status_pressure"); + for scenario in [ + PressureScenario { + task_count: 64, + rounds: 4, + split: 4, + max_connections_per_server: 4, + }, + PressureScenario { + task_count: 128, + rounds: 4, + split: 8, + max_connections_per_server: 8, + }, + ] { + group.throughput(Throughput::Elements( + (scenario.task_count * scenario.rounds) as u64, + )); + group.bench_with_input( + BenchmarkId::new("tell_status", scenario.task_count), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_pressure_dispatcher(scenario), + |(mut dispatcher, gids)| { + let calls = + run_tell_status_pressure(&mut dispatcher, &gids, scenario.rounds); + assert_eq!(calls, scenario.task_count * scenario.rounds); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_mixed_rpc_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("rpc_mixed_pressure"); + for scenario in [ + PressureScenario { + task_count: 96, + rounds: 6, + split: 6, + max_connections_per_server: 6, + }, + PressureScenario { + task_count: 192, + rounds: 6, + split: 8, + max_connections_per_server: 8, + }, + ] { + group.throughput(Throughput::Elements((scenario.rounds * 3) as u64)); + group.bench_with_input( + BenchmarkId::new("mixed_rpc", scenario.task_count), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_pressure_dispatcher(scenario), + |(mut dispatcher, gids)| { + let calls = run_mixed_rpc_pressure(&mut dispatcher, &gids, scenario.rounds); + assert_eq!(calls, scenario.rounds * 3); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_resource_limit_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("rpc_speed_limit_pressure"); + for scenario in [ + ResourceLimitScenario { + task_count: 32, + rounds: 6, + global_download_limit: 2_560, + per_download_limit: 160, + global_upload_limit: 1_280, + per_upload_limit: 80, + disk_cache_bytes: 8 * 1024 * 1024, + }, + ResourceLimitScenario { + task_count: 64, + rounds: 6, + global_download_limit: 5_120, + per_download_limit: 120, + global_upload_limit: 2_560, + per_upload_limit: 60, + disk_cache_bytes: 16 * 1024 * 1024, + }, + ] { + group.throughput(Throughput::Elements((scenario.rounds * 2) as u64)); + group.bench_with_input( + BenchmarkId::new("speed_limit", scenario.task_count), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_limited_dispatcher(scenario), + |(mut dispatcher, gids)| { + let calls = run_resource_limit_pressure(&mut dispatcher, &gids, scenario); + assert_eq!(calls, scenario.rounds * 2); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_shared_runtime_fairness_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("rpc_shared_runtime_fairness_pressure"); + for scenario in [ + SharedRuntimeFairnessScenario { + label: "three_way_rebalance", + task_count: 3, + rounds: 4, + global_download_limit: 1_200, + default_per_download_limit: 900, + constrained_per_download_limit: 250, + global_upload_limit: 600, + default_per_upload_limit: 500, + constrained_per_upload_limit: 120, + }, + SharedRuntimeFairnessScenario { + label: "four_way_rebalance", + task_count: 4, + rounds: 4, + global_download_limit: 1_600, + default_per_download_limit: 900, + constrained_per_download_limit: 220, + global_upload_limit: 800, + default_per_upload_limit: 500, + constrained_per_upload_limit: 100, + }, + ] { + group.throughput(Throughput::Elements( + (scenario.task_count * scenario.rounds) as u64, + )); + group.bench_with_input( + BenchmarkId::new("shared_runtime_fairness", scenario.label), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_shared_runtime_fairness_dispatcher(scenario), + |(mut dispatcher, gids)| { + let calls = + run_shared_runtime_fairness_pressure(&mut dispatcher, &gids, scenario); + assert_eq!(calls, scenario.rounds * (scenario.task_count + 1)); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure/runtime_engine_pressure.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure/runtime_engine_pressure.rs new file mode 100644 index 0000000..9efda70 --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure/runtime_engine_pressure.rs @@ -0,0 +1,206 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "criterion bench entry points are re-exported only to the private bench root module" +)] + +use super::support::{ + BenchmarkId, Criterion, DownloadEngine, DownloadStatus, PieceId, PieceState, RuntimeConfig, + Throughput, +}; + +#[derive(Clone, Copy, Debug)] +struct BackpressureScenario { + task_count: usize, + rounds: usize, + disk_cache_bytes: u64, + split: usize, + max_connections_per_server: usize, +} + +fn seed_instrumented_engine(task_count: usize) -> DownloadEngine { + let runtime = RuntimeConfig { + split: 4, + max_connections_per_server: 4, + max_connection_per_server: 4, + min_split_size: 1024, + piece_length: 1024, + ..RuntimeConfig::default() + }; + let mut engine = DownloadEngine::with_runtime(runtime); + for i in 0..task_count { + let gid = engine + .add_uri(format!("magnet:?xt=urn:btih:{:040x}", 90_001 + i)) + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly inserted benchmark group should exist"); + group.set_status(if i % 3 == 0 { + DownloadStatus::Waiting + } else { + DownloadStatus::Active + }); + group.set_total_length(8 * 1024); + group.set_completed_length((i % 4) as u64 * 1024); + group.set_piece_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); + } + + for _ in 0..3 { + let _ = engine.schedule_once(); + } + engine +} + +fn seed_backpressure_engine(scenario: BackpressureScenario) -> DownloadEngine { + let runtime = RuntimeConfig { + split: scenario.split, + max_connections_per_server: scenario.max_connections_per_server, + max_connection_per_server: scenario.max_connections_per_server, + min_split_size: 1024, + piece_length: 1024, + disk_cache_bytes: scenario.disk_cache_bytes, + ..RuntimeConfig::default() + }; + let mut engine = DownloadEngine::with_runtime(runtime); + for i in 0..scenario.task_count { + let gid = engine + .add_uri(format!("https://example.org/backpressure-{i}.bin")) + .gid(); + let group = engine + .handle_mut(gid) + .expect("newly inserted backpressure group should exist"); + group.set_status(match i % 4 { + 0 => DownloadStatus::Waiting, + 2 => DownloadStatus::Error, + _ => DownloadStatus::Active, + }); + group.set_total_length(16 * 1024); + group.set_completed_length((i % 8) as u64 * 1024); + group.set_piece_length(1024); + group.set_download_speed(2_500 + i as u64 * 11); + group.set_upload_speed(900 + i as u64 * 5); + group.set_num_connections((scenario.max_connections_per_server.min(8)) as u32); + group.set_retry_count((i % 3) as u32); + 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); + } + + for _ in 0..4 { + let _ = engine.schedule_once(); + } + engine +} + +fn run_runtime_snapshot_pressure(engine: &mut DownloadEngine, rounds: usize) -> usize { + let gids = engine.registry().handles().collect::>(); + for round in 0..rounds { + let gid = gids[round % gids.len()].gid(); + let group = engine + .handle_mut(gid) + .expect("benchmark group should still exist"); + group.set_download_speed(600 + round as u64 * 10); + group.set_upload_speed(200 + round as u64 * 5); + let _ = engine.schedule_once(); + let runtime = engine.runtime_instrumentation_snapshot(); + assert!(runtime.download_count >= gids.len()); + assert!(runtime.scheduler_counters.schedule_run_count >= 1); + } + rounds +} + +fn run_backpressure_runtime_pressure( + engine: &mut DownloadEngine, + scenario: BackpressureScenario, +) -> usize { + let gids = engine.registry().handles().collect::>(); + for round in 0..scenario.rounds { + let gid = gids[round % gids.len()].gid(); + let group = engine + .handle_mut(gid) + .expect("backpressure benchmark group should still exist"); + group.set_retry_count((round % 5) as u32); + group.set_status(if round % 3 == 0 { + DownloadStatus::Waiting + } else { + DownloadStatus::Active + }); + group.set_download_speed(4_000 + round as u64 * 40); + group.set_upload_speed(1_500 + round as u64 * 25); + + let _ = engine.schedule_once(); + let runtime = engine.runtime_instrumentation_snapshot(); + assert_eq!( + runtime.configured_disk_cache_bytes, + scenario.disk_cache_bytes + ); + assert!(runtime.total_active_segments >= 1); + assert!(runtime.scheduler_counters.schedule_run_count >= 1); + } + scenario.rounds +} + +pub(super) fn bench_runtime_snapshot_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("runtime_snapshot_pressure"); + for task_count in [64usize, 128, 256] { + group.throughput(Throughput::Elements(task_count as u64)); + group.bench_with_input( + BenchmarkId::new("runtime_snapshot", task_count), + &task_count, + |b, &task_count| { + b.iter_batched( + || seed_instrumented_engine(task_count), + |mut engine| { + let observations = run_runtime_snapshot_pressure(&mut engine, 8); + assert_eq!(observations, 8); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} + +pub(super) fn bench_scheduler_backpressure_pressure(c: &mut Criterion) { + let mut group = c.benchmark_group("scheduler_backpressure_pressure"); + for scenario in [ + BackpressureScenario { + task_count: 64, + rounds: 10, + disk_cache_bytes: 4 * 1024 * 1024, + split: 4, + max_connections_per_server: 4, + }, + BackpressureScenario { + task_count: 128, + rounds: 10, + disk_cache_bytes: 32 * 1024 * 1024, + split: 8, + max_connections_per_server: 8, + }, + ] { + group.throughput(Throughput::Elements(scenario.task_count as u64)); + group.bench_with_input( + BenchmarkId::new("backpressure", scenario.task_count), + &scenario, + |b, &scenario| { + b.iter_batched( + || seed_backpressure_engine(scenario), + |mut engine| { + let observations = run_backpressure_runtime_pressure(&mut engine, scenario); + assert_eq!(observations, scenario.rounds); + }, + criterion::BatchSize::SmallInput, + ); + }, + ); + } + group.finish(); +} diff --git a/crates/aria2-rust-pro-tests/benches/rpc_pressure/support.rs b/crates/aria2-rust-pro-tests/benches/rpc_pressure/support.rs new file mode 100644 index 0000000..8b5e945 --- /dev/null +++ b/crates/aria2-rust-pro-tests/benches/rpc_pressure/support.rs @@ -0,0 +1,41 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "private criterion bench modules share fixtures through pub(super) support exports" +)] + +pub(super) use std::{ + collections::BTreeMap, + fs, + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub(super) use aria2_rust_pro_cli::{Invocation, execute_runtime_with_downloader}; +pub(super) use aria2_rust_pro_core::{ + DownloadEngine, DownloadStatus, PieceId, PieceState, RuntimeConfig, +}; +pub(super) use aria2_rust_pro_protocol::{ + ReqwestHttpConnector, TorrentPeerModel, TrackerPeerListModel, TrackerResponseModel, + downloader::ConnectorBackedDownloader, +}; +pub(super) use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue}; +pub(super) use criterion::{BenchmarkId, Criterion, Throughput}; + +pub(super) const BT_TORRENT_FIXTURE: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + +pub(super) fn rpc_request(method: RpcMethod, params: Vec) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.as_str().to_owned(), + params, + meta: Default::default(), + } +} diff --git a/crates/aria2-rust-pro-tests/src/lib.rs b/crates/aria2-rust-pro-tests/src/lib.rs new file mode 100644 index 0000000..f716927 --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/lib.rs @@ -0,0 +1,36 @@ +#![forbid(unsafe_code)] +#![doc = "Workspace integration and benchmark tests for aria2-rust-pro."] + +#[cfg(test)] +use aria2_rust_pro_cli as _; +#[cfg(test)] +use criterion as _; + +#[cfg(test)] +#[expect( + clippy::arithmetic_side_effects, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::indexing_slicing, + clippy::integer_division, + clippy::too_many_lines, + reason = "integration tests keep protocol/RPC fixtures explicit so regressions remain auditable" +)] +mod tests { + use support::*; + + #[path = "bt_status_and_selection.rs"] + mod bt_status_and_selection; + #[path = "dht_and_peer_wire.rs"] + mod dht_and_peer_wire; + #[path = "foundations_and_protocol.rs"] + mod foundations_and_protocol; + #[path = "rpc_parity.rs"] + mod rpc_parity; + #[path = "rpc_pressure_and_runtime.rs"] + mod rpc_pressure_and_runtime; + #[path = "support.rs"] + mod support; + #[path = "tracker_and_surface_regression.rs"] + mod tracker_and_surface_regression; +} diff --git a/crates/aria2-rust-pro-tests/src/tests/bt_status_and_selection.rs b/crates/aria2-rust-pro-tests/src/tests/bt_status_and_selection.rs new file mode 100644 index 0000000..501966a --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/bt_status_and_selection.rs @@ -0,0 +1,701 @@ +use super::*; + +#[test] +fn add_torrent_registers_runtime_backed_bt_metadata_surfaces() { + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let mut dispatcher = InProcessRpcDispatcher::new(); + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + 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: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false))); + assert!(matches!( + payload.get("announceList"), + Some(RpcValue::Array(tiers)) if !tiers.is_empty() + )); + assert!(matches!( + payload.get("magnetUri"), + Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:") + )); + } + other => panic!("unexpected tellStatus after addTorrent: {other:?}"), + } + + let files = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetFiles.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match files.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("path"), + Some(&RpcValue::String("ubuntu.iso".to_owned())) + ); + assert_eq!( + file.get("length"), + Some(&RpcValue::String("32768".to_owned())) + ); + } + other => panic!("unexpected getFiles entry after addTorrent: {other:?}"), + }, + other => panic!("unexpected getFiles result after addTorrent: {other:?}"), + } + + let servers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetServers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + let error = servers + .error + .expect("getServers should reject non-active BT downloads"); + assert!( + error + .message + .contains(&format!("No active download for GID#{gid}")) + ); +} + +#[test] +fn add_uri_magnet_registers_bt_runtime_status_surfaces() { + let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-magnet.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result: {other:?}"), + }; + + 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)], + meta: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true))); + assert!(matches!( + payload.get("magnetUri"), + Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:") + )); + assert!(matches!( + payload.get("announceList"), + Some(RpcValue::Array(tiers)) if !tiers.is_empty() + )); + } + other => panic!("unexpected tellStatus after addUri magnet: {other:?}"), + } +} + +#[test] +fn tracker_announce_ingestion_populates_peers_and_preserves_bt_servers() { + fn bencode_int(value: i64) -> Vec { + format!("i{value}e").into_bytes() + } + + fn bencode_bytes(value: &[u8]) -> Vec { + let mut out = format!("{}:", value.len()).into_bytes(); + out.extend_from_slice(value); + out + } + + fn bencode_list(values: Vec>) -> Vec { + let mut out = vec![b'l']; + for value in values { + out.extend_from_slice(&value); + } + out.push(b'e'); + out + } + + fn bencode_dict(entries: Vec<(&str, Vec)>) -> Vec { + let mut out = vec![b'd']; + for (key, value) in entries { + out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes()); + out.extend_from_slice(&value); + } + out.push(b'e'); + out + } + + let magnet = "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=bt-peers.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result: {other:?}"), + }; + + let announce_bytes = bencode_dict(vec![ + ("interval", bencode_int(1800)), + ("tracker id", bencode_bytes(b"tracker-session-1")), + ( + "peers", + bencode_list(vec![ + bencode_dict(vec![ + ("ip", bencode_bytes(b"203.0.113.10")), + ("port", bencode_int(51413)), + ("peer id", bencode_bytes(b"-AZ2060-123456789012")), + ("client", bencode_bytes(b"Azureus 2.0.6.0")), + ("choked", bencode_int(0)), + ("interested", bencode_int(1)), + ]), + bencode_dict(vec![ + ("ip", bencode_bytes(b"203.0.113.11")), + ("port", bencode_int(51414)), + ("choked", bencode_int(1)), + ("interested", bencode_int(0)), + ]), + ]), + ), + ]); + + let announce = TrackerResponseModel::from_announce_bytes(&announce_bytes) + .expect("announce response should parse"); + dispatcher + .apply_tracker_announce_result(&gid, &announce) + .expect("tracker announce should ingest"); + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match peers.result { + Some(RpcValue::Array(entries)) => { + assert!(!entries.is_empty(), "expected concrete peer entries"); + match entries.first() { + Some(RpcValue::Object(peer)) => { + assert!(matches!(peer.get("ip"), Some(RpcValue::String(_)))); + assert!(matches!(peer.get("port"), Some(RpcValue::String(_)))); + } + other => panic!("unexpected getPeers entry: {other:?}"), + } + } + other => panic!("unexpected getPeers result: {other:?}"), + } + + let servers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetServers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + let error = servers + .error + .expect("getServers should reject non-active BT downloads"); + assert!( + error + .message + .contains(&format!("No active download for GID#{gid}")) + ); +} + +#[test] +fn live_http_tracker_announce_round_trips_into_dispatcher_peer_visibility() { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + 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 payload = { + let mut payload = b"d8:intervali900e5:peers6:".to_vec(); + payload.extend_from_slice(&[127, 0, 0, 1, 0x1A, 0xE1]); + payload.extend_from_slice(b"e"); + payload + }; + let handle = thread::spawn(move || { + 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_text = String::from_utf8_lossy(&request[..read]); + assert!(request_text.starts_with("GET /announce?")); + assert!(request_text.contains("compact=1")); + 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"); + }); + + let transport = ReqwestTrackerTransport::new().expect("reqwest tracker transport should build"); + let announce = transport + .announce(&TrackerRequestModel { + announce_url: format!("http://{addr}/announce"), + info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), + peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(), + port: 6881, + uploaded: 0, + downloaded: 0, + left: 2048, + event: Some("started".to_owned()), + compact: true, + numwant: Some(10), + }) + .expect("live announce should succeed"); + + let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-live-tracker.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + dispatcher + .apply_tracker_announce_result(&gid, &announce) + .expect("tracker announce should ingest"); + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match peers.result { + Some(RpcValue::Array(items)) => match items.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("127.0.0.1".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); + } + other => panic!("unexpected peer payload after live tracker announce: {other:?}"), + }, + other => panic!("unexpected getPeers result after live tracker announce: {other:?}"), + } + + handle.join().expect("tracker server thread should join"); +} + +#[test] +fn bt_change_option_select_file_round_trips_through_get_files_selected_flags() { + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let mut dispatcher = InProcessRpcDispatcher::new(); + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let change = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), + params: vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String(String::new()), + )])), + ], + meta: Default::default(), + }); + assert!( + change.error.is_some(), + "empty select-file value should be rejected to protect BT file selection contract" + ); + + let change = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), + params: vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("1".to_owned()), + )])), + ], + meta: Default::default(), + }); + assert_eq!(change.result, Some(RpcValue::String("OK".to_owned()))); + + let files = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetFiles.as_str().to_owned(), + params: vec![RpcValue::String(gid)], + meta: Default::default(), + }); + match files.result { + Some(RpcValue::Array(entries)) => { + assert_eq!(entries.len(), 1, "fixture torrent currently has one file"); + match entries.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("selected"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected getFiles entry: {other:?}"), + } + } + other => panic!("unexpected getFiles response: {other:?}"), + } +} + +#[test] +fn bt_pause_and_selected_state_are_persisted_in_saved_session_file() { + let session_root = std::env::temp_dir().join(format!( + "aria2-rust-pro-tests-bt-session-{}", + std::process::id() + )); + let _ = std::fs::create_dir_all(&session_root); + let session_path = session_root.join("session.txt"); + let mut dispatcher = InProcessRpcDispatcher::with_runtime( + RuntimeConfig::default().with_session_path(session_path.clone()), + ); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let _ = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), + params: vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("1".to_owned()), + )])), + ], + meta: Default::default(), + }); + let pause = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2Pause.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + assert_eq!(pause.result, Some(RpcValue::String(gid.clone()))); + + let save = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2SaveSession.as_str().to_owned(), + params: vec![], + meta: Default::default(), + }); + assert_eq!(save.result, Some(RpcValue::String("OK".to_owned()))); + + let loaded = load_session_file(&session_path).expect("saved session file should load"); + let persisted = loaded + .entries + .iter() + .find(|entry| entry.gid == gid) + .expect("saved session should include paused BT gid"); + let metadata = persisted + .metadata + .as_ref() + .expect("session entry should include metadata"); + assert_eq!(metadata.get("status"), Some(&"paused".to_owned())); + assert_eq!( + metadata.get("bt.file.0.selected"), + Some(&"true".to_owned()), + "session metadata should preserve BT selected-file flag for reload path" + ); + + let _ = std::fs::remove_file(session_path); + let _ = std::fs::remove_dir_all(session_root); +} + +#[test] +fn bt_metadata_only_must_not_report_false_completion_lengths() { + let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-metadata-only.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result: {other:?}"), + }; + + 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)], + meta: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true))); + assert_ne!( + payload.get("status"), + Some(&RpcValue::String("complete".to_owned())), + "metadata-only BT should not look fully complete before payload download" + ); + assert_eq!( + payload.get("completedLength"), + Some(&RpcValue::String("0".to_owned())), + "metadata-only BT should not fake payload completed length" + ); + } + other => panic!("unexpected tellStatus payload: {other:?}"), + } +} + +#[test] +fn bt_select_file_pause_resume_and_save_session_contract_is_stable() { + let session_root = std::env::temp_dir().join(format!( + "aria2-rust-pro-tests-bt-select-pause-resume-{}", + std::process::id() + )); + let _ = std::fs::create_dir_all(&session_root); + let session_path = session_root.join("session.txt"); + let mut dispatcher = InProcessRpcDispatcher::with_runtime( + RuntimeConfig::default().with_session_path(session_path.clone()), + ); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let change = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), + params: vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("1".to_owned()), + )])), + ], + meta: Default::default(), + }); + assert_eq!(change.result, Some(RpcValue::String("OK".to_owned()))); + + let pause = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2Pause.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + assert!(pause.error.is_none()); + + let unpause = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2Unpause.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + assert!(unpause.error.is_none()); + + let repause = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2Pause.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + assert!(repause.error.is_none()); + + let files = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetFiles.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match files.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(file)) => assert_eq!( + file.get("selected"), + Some(&RpcValue::String("true".to_owned())) + ), + other => panic!("unexpected getFiles entry after pause/resume: {other:?}"), + }, + other => panic!("unexpected getFiles payload after pause/resume: {other:?}"), + } + + let save = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2SaveSession.as_str().to_owned(), + params: vec![], + meta: Default::default(), + }); + assert_eq!(save.result, Some(RpcValue::String("OK".to_owned()))); + + let loaded = load_session_file(&session_path).expect("saved session file should load"); + let entry = loaded + .entries + .iter() + .find(|entry| entry.gid == gid) + .expect("saved session should include BT gid"); + let metadata = entry + .metadata + .as_ref() + .expect("saved session entry should include metadata"); + assert_eq!(metadata.get("status"), Some(&"paused".to_owned())); + assert_eq!(metadata.get("bt.file.0.selected"), Some(&"true".to_owned())); + + let _ = std::fs::remove_file(session_path); + let _ = std::fs::remove_dir_all(session_root); +} + +#[test] +fn bt_seeding_and_share_visibility_fields_exist_and_are_type_stable() { + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let mut dispatcher = InProcessRpcDispatcher::new(); + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + 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)], + meta: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!( + matches!(payload.get("status"), Some(RpcValue::String(_)),), + "status field should remain visible for BT" + ); + assert!( + payload.contains_key("seeder"), + "BT status should expose seeder visibility field" + ); + assert!( + payload.contains_key("seeders"), + "BT status should expose seeders visibility field" + ); + assert!( + payload.contains_key("numSeeders"), + "BT status should expose numSeeders visibility field" + ); + assert!( + payload.contains_key("shareRatio"), + "BT status should expose shareRatio visibility field" + ); + assert!( + payload.contains_key("shareRatioProgress"), + "BT status should expose shareRatioProgress visibility field" + ); + assert!( + payload.contains_key("shareRatioRemaining"), + "BT status should expose shareRatioRemaining visibility field" + ); + assert!( + payload.contains_key("shareTime"), + "BT status should expose shareTime visibility field" + ); + if let Some(value) = payload.get("shareRatio") { + assert!( + matches!(value, RpcValue::String(_) | RpcValue::Number(_)), + "shareRatio should stay scalar" + ); + } + if let Some(value) = payload.get("shareTime") { + assert!( + matches!(value, RpcValue::String(_) | RpcValue::Number(_)), + "shareTime should stay scalar" + ); + } + } + other => panic!("unexpected tellStatus payload for BT share visibility: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-tests/src/tests/dht_and_peer_wire.rs b/crates/aria2-rust-pro-tests/src/tests/dht_and_peer_wire.rs new file mode 100644 index 0000000..b4c12a4 --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/dht_and_peer_wire.rs @@ -0,0 +1,682 @@ +use super::*; + +#[test] +fn peer_wire_exchange_keeps_bt_peer_surface_visible_after_tracker_ingest() { + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let mut dispatcher = InProcessRpcDispatcher::new(); + let add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + let bootstrap_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: Default::default(), + }); + let info_hash = match bootstrap_status.result { + Some(RpcValue::Object(payload)) => match payload.get("magnetUri") { + Some(RpcValue::String(uri)) => info_hash_bytes(uri), + other => panic!("unexpected magnetUri payload after addTorrent: {other:?}"), + }, + other => panic!("unexpected tellStatus bootstrap payload: {other:?}"), + }; + + dispatcher + .apply_tracker_announce_result( + &gid, + &TrackerResponseModel { + peers: TrackerPeerListModel { + interval_sec: 900, + peers: vec![TorrentPeerModel { + peer_id: None, + ip: "198.51.100.20".to_owned(), + port: 51413, + client_name: None, + interested: false, + choked: true, + }], + min_interval_sec: None, + tracker_id: Some("tracker-session-bt".to_owned()), + }, + scrape: None, + }, + ) + .expect("tracker announce should seed a BT peer"); + + let connector = FixedPeerWireConnector { + response_payload: peer_wire_payload( + info_hash, + *b"-TR3000-RUNTIME-PEER", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[ + true, true, + ])), + PeerWireMessageKind::Have(1), + ], + ), + }; + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange should succeed"); + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match peers.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("peerId"), + Some(&RpcValue::String( + "2d5452333030302d52554e54494d452d50454552".to_owned() + )) + ); + assert_eq!( + peer.get("peerChoking"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + peer.get("seeder"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected getPeers row after peer-wire exchange: {other:?}"), + }, + other => panic!("unexpected getPeers result after peer-wire exchange: {other:?}"), + } + + 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)], + meta: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + payload.get("numSeeders"), + Some(&RpcValue::String("1".to_owned())) + ); + } + other => panic!("unexpected tellStatus payload after peer-wire exchange: {other:?}"), + } +} + +#[test] +fn execute_dht_get_peers_refreshes_peer_list_and_connection_count() { + let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-dht-exec.iso"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + let transport = FixedDhtTransport { + response: DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x22; 20], + Some(b"dht-get-peers-token".to_vec()), + Some(compact_node(0x44, [203, 0, 113, 20], 6885)), + vec![ + compact_peer([203, 0, 113, 10], 51413), + compact_peer([203, 0, 113, 11], 51414), + ], + ), + }; + dispatcher + .execute_dht_get_peers(&gid, &transport) + .expect("dht get_peers should succeed"); + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + match peers.result { + Some(RpcValue::Array(entries)) => { + assert_eq!( + entries.len(), + 2, + "dht peers should replace the visible peer list" + ); + let ports = entries + .into_iter() + .filter_map(|entry| match entry { + RpcValue::Object(peer) => peer.get("port").cloned(), + _ => None, + }) + .collect::>(); + assert!(ports.contains(&RpcValue::String("51413".to_owned()))); + assert!(ports.contains(&RpcValue::String("51414".to_owned()))); + } + other => panic!("unexpected getPeers result after dht execution: {other:?}"), + } + + 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)], + meta: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!( + payload.get("connections"), + Some(&RpcValue::String("2".to_owned())) + ); + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + } + other => panic!("unexpected tellStatus payload after dht execution: {other:?}"), + } +} + +#[test] +fn dht_peer_wire_and_rpc_views_stay_in_sync_for_piece_progress_regression() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash")); + + let dht_source = DhtNodeModel { + node_id: String::new(), + address: "203.0.113.200".to_owned(), + port: 6881, + }; + let dht_response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x91; 20], + Some(b"dht-piece-token".to_vec()), + Some(compact_node(0x62, [203, 0, 113, 201], 6882)), + vec![compact_peer([198, 51, 100, 44], 51413)], + ); + dispatcher + .apply_dht_get_peers_result(&gid, &dht_source, &dht_response) + .expect("dht peer discovery should seed a peer-wire target"); + + let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + *b"-RTK0001-12345678901", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, false])), + PeerWireMessageKind::Piece(PeerWirePieceBlockModel { + piece_index: 0, + block_offset: 0, + block: vec![0xAB; 16_384], + }), + ], + )); + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange should succeed after dht discovery"); + + let seen = connector.seen(); + assert_eq!(seen.len(), 1, "peer-wire transport should see one exchange"); + assert_eq!(seen[0].endpoint.address, "198.51.100.44:51413"); + let (handshake, _consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload) + .expect("outbound peer-wire payload should begin with a handshake"); + assert_eq!(handshake.info_hash, info_hash); + + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!( + status.get("completedLength"), + Some(&RpcValue::String("16384".to_owned())) + ); + assert_eq!( + status.get("completedPieces"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + status.get("bitfield"), + Some(&RpcValue::String("20".to_owned())) + ); + assert_eq!( + status.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + + let files = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + ))); + match files.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("completedLength"), + Some(&RpcValue::String("16384".to_owned())) + ); + assert_eq!( + file.get("bitfield"), + Some(&RpcValue::String("20".to_owned())) + ); + } + other => { + panic!("unexpected getFiles payload after peer-wire piece exchange: {other:?}") + } + } + + let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid)], + ))); + match peers.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("198.51.100.44".to_owned())) + ); + assert_eq!( + peer.get("peerChoking"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + peer.get("downloadSpeed"), + Some(&RpcValue::String("16384".to_owned())) + ); + } + other => { + panic!("unexpected getPeers payload after peer-wire piece exchange: {other:?}") + } + } +} + +#[test] +fn tracker_scrape_then_dht_refresh_preserves_seed_counts_and_server_rows() { + let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-handoff.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = match dispatcher + .dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(magnet.to_owned())], + )) + .result + { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + let tracker_announce = TrackerResponseModel { + peers: TrackerPeerListModel { + interval_sec: 900, + peers: vec![TorrentPeerModel { + peer_id: None, + ip: "198.51.100.60".to_owned(), + port: 51413, + client_name: Some("tracker-peer".to_owned()), + interested: true, + choked: false, + }], + min_interval_sec: None, + tracker_id: Some("bt-tracker".to_owned()), + }, + scrape: None, + }; + dispatcher + .apply_tracker_announce_result(&gid, &tracker_announce) + .expect("tracker announce should populate peer and tracker views"); + dispatcher + .apply_tracker_scrape_result( + &gid, + None, + &TrackerScrapeModel { + complete: Some(9), + downloaded: Some(12), + incomplete: Some(4), + files: vec![TrackerScrapeFileModel { + info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), + complete: Some(9), + downloaded: Some(12), + incomplete: Some(4), + }], + }, + ) + .expect("tracker scrape should populate seeding counts"); + + let dht_response = DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x33; 20], + Some(b"handoff".to_vec()), + Some(compact_node(0x71, [203, 0, 113, 61], 6889)), + vec![compact_peer([203, 0, 113, 62], 6001)], + ); + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.60".to_owned(), + port: 6881, + }, + &dht_response, + ) + .expect("dht refresh should replace the visible peer view"); + + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!( + status.get("numSeeders"), + Some(&RpcValue::String("9".to_owned())) + ); + assert_eq!( + status.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + assert!(matches!( + status.get("announceList"), + Some(RpcValue::Array(tiers)) if !tiers.is_empty() + )); + + let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!( + peers.len(), + 1, + "dht snapshot should replace tracker peer rows" + ); + match peers.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("203.0.113.62".to_owned())) + ); + assert_eq!(peer.get("port"), Some(&RpcValue::String("6001".to_owned()))); + } + other => panic!("unexpected getPeers payload after tracker+dht handoff: {other:?}"), + } + + let servers = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetServers, + vec![RpcValue::String(gid.clone())], + )); + let error = servers + .error + .expect("getServers should reject non-active BT downloads"); + assert!( + error + .message + .contains(&format!("No active download for GID#{gid}")) + ); +} + +#[test] +fn peer_wire_exchange_follows_dht_peer_refresh_under_swarm_churn() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash")); + + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.70".to_owned(), + port: 6881, + }, + &DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x31; 20], + Some(b"churn-a".to_vec()), + None, + vec![compact_peer([198, 51, 100, 70], 51413)], + ), + ) + .expect("first dht refresh should seed peer A"); + + let connector_a = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + *b"-PC0001-CHURN-PEERA1", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Piece(PeerWirePieceBlockModel { + piece_index: 0, + block_offset: 0, + block: vec![0xAA; 16_384], + }), + ], + )); + dispatcher + .execute_peer_wire_exchange(&gid, &connector_a) + .expect("first peer-wire exchange should succeed"); + + let seen_a = connector_a.seen(); + assert_eq!(seen_a.len(), 1, "peer A should receive one exchange"); + assert_eq!(seen_a[0].endpoint.address, "198.51.100.70:51413"); + + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.71".to_owned(), + port: 6882, + }, + &DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x32; 20], + Some(b"churn-b".to_vec()), + None, + vec![compact_peer([198, 51, 100, 71], 51414)], + ), + ) + .expect("second dht refresh should replace visible peer with peer B"); + + let connector_b = FakePeerWireConnector::new(peer_wire_handshake_and_frames( + info_hash, + *b"-PC0001-CHURN-PEERB1", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Piece(PeerWirePieceBlockModel { + piece_index: 1, + block_offset: 0, + block: vec![0xBB; 16_384], + }), + ], + )); + dispatcher + .execute_peer_wire_exchange(&gid, &connector_b) + .expect("second peer-wire exchange should follow refreshed peer"); + + let seen_b = connector_b.seen(); + assert_eq!(seen_b.len(), 1, "peer B should receive one exchange"); + assert_eq!(seen_b[0].endpoint.address, "198.51.100.71:51414"); + + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!( + status.get("completedLength"), + Some(&RpcValue::String("32768".to_owned())) + ); + assert_eq!( + status.get("completedPieces"), + Some(&RpcValue::String("2".to_owned())) + ); + assert_eq!( + status.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + assert_eq!( + status.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + assert_eq!( + status.get("seeder"), + Some(&RpcValue::String("false".to_owned())) + ); + + let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid)], + ))); + assert_eq!( + peers.len(), + 1, + "refreshed DHT peer view should replace stale peer A" + ); + match peers.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("198.51.100.71".to_owned())) + ); + assert_eq!( + peer.get("port"), + Some(&RpcValue::String("51414".to_owned())) + ); + } + other => panic!("unexpected peer payload after swarm churn exchange: {other:?}"), + } +} + +#[test] +fn dht_find_node_and_announce_peer_runtime_handoff_stays_coherent() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash")); + + let find_node_transport = RecordingDhtTransport::new(DhtMessageModel::find_node_response( + b"fn".to_vec(), + vec![0x41; 20], + vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel { + node_id: [0x77; 20], + address: [203, 0, 113, 99], + port: 6891, + }], + )); + dispatcher + .execute_dht_find_node(&gid, &find_node_transport) + .expect("dht find_node should succeed"); + + let find_node_seen = find_node_transport.seen(); + assert_eq!( + find_node_seen.len(), + 1, + "find_node should send exactly one query" + ); + match &find_node_seen[0].1.body { + DhtMessageBody::Query(DhtQueryModel::FindNode(query)) => { + assert_eq!(query.target, info_hash); + } + other => panic!("unexpected find_node query payload: {other:?}"), + } + + dispatcher + .apply_dht_get_peers_result( + &gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.99".to_owned(), + port: 6891, + }, + &DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x55; 20], + Some(b"dht-announce-token".to_vec()), + None, + vec![compact_peer([198, 51, 100, 9], 51413)], + ), + ) + .expect("get_peers handoff should cache announce token"); + + let announce_transport = RecordingDhtTransport::new(DhtMessageModel::ping_response( + b"ap".to_vec(), + vec![0x66; 20], + )); + dispatcher + .execute_dht_announce_peer(&gid, &announce_transport) + .expect("dht announce_peer should succeed after token handoff"); + + let announce_seen = announce_transport.seen(); + assert_eq!( + announce_seen.len(), + 1, + "announce_peer should send exactly one query" + ); + match &announce_seen[0].1.body { + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => { + assert_eq!(query.info_hash, info_hash); + assert_eq!(query.token, b"dht-announce-token".to_vec()); + assert_eq!(query.port, 6881); + assert!(!query.implied_port); + } + other => panic!("unexpected announce_peer query payload: {other:?}"), + } + + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true))); + assert_eq!( + status.get("connections"), + Some(&RpcValue::String("1".to_owned())) + ); + + let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + ))); + match peers.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("ip"), + Some(&RpcValue::String("198.51.100.9".to_owned())) + ); + assert_eq!( + peer.get("port"), + Some(&RpcValue::String("51413".to_owned())) + ); + } + other => panic!("unexpected peer payload after announce handoff: {other:?}"), + } +} diff --git a/crates/aria2-rust-pro-tests/src/tests/foundations_and_protocol.rs b/crates/aria2-rust-pro-tests/src/tests/foundations_and_protocol.rs new file mode 100644 index 0000000..1716639 --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/foundations_and_protocol.rs @@ -0,0 +1,284 @@ +use super::*; + +#[test] +fn phase_zero_workspace_tracks_goal_contract() { + assert_eq!(BASELINE_COMMIT, "1f1323128cae942f5440c035cb5f42788b3de33f"); + assert!(is_required_pro_option("retry-on-403")); + assert!(is_required_protocol("xml-rpc")); + assert!(is_required_rpc_method("aria2.addMetalink")); + assert_eq!(Protocol::Https.as_str(), "https"); + assert_eq!(ControlFileVersion::CURRENT.major(), 1); + assert_eq!( + GoalProgress::new("Phase 0 - Foundation").phase_name(), + "Phase 0 - Foundation" + ); +} + +#[test] +fn streamed_execution_truth_surfaces_align_across_storage_and_protocol() { + let payload = b"stream-truth"; + let mut sink = ObservedByteSink::with_unbounded_retention(); + sink.write(payload) + .expect("observed sink write is infallible"); + + let mut checksum = ChecksumSpec { + algorithm: "md5".to_owned(), + expected_hex: String::new(), + actual_hex: None, + }; + checksum.expected_hex = checksum + .compute_actual_hex(sink.retained()) + .expect("md5 digest should be computable"); + + let response = HttpResponseModel { + status: 200, + reason: "OK".to_owned(), + version: HttpVersion::Http11, + headers: HttpResponseHeaders { + headers: Vec::new(), + }, + body: ResponseBody::Streamed { + expected_len: None, + observed_len: Some(sink.observed_len()), + observed_digest: checksum.compute_actual_hex(sink.retained()), + temp_path: None, + }, + content_range: None, + partial_content: false, + checksum: Some(checksum), + redirected_from: None, + }; + + let completion = response.completion_model(); + assert_eq!(response.completed_length(), sink.observed_len()); + assert_eq!(completion.completed_length, sink.observed_len()); + assert!(completion.checksum_seen); + assert!(completion.checksum_verified); + assert_eq!(completion.state, HttpCompletionState::Verified); +} + +#[test] +fn add_metalink_prefers_protocol_selected_resource_over_first_resource() { + let metalink_xml = r#" + + +http://fallback.example.org/metalink-priority.bin +https://preferred.example.org/metalink-priority.bin + +"#; + let parsed = parse_metalink_document(metalink_xml).expect("fixture metalink should parse"); + let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate(&parsed) + .map(|(_, resource)| resource.url.clone()) + .expect("fixture should expose a preferred resource"); + + let mut dispatcher = InProcessRpcDispatcher::new(); + let add_response = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddMetalink.as_str().to_owned(), + params: vec![RpcValue::String(metalink_xml.to_owned())], + meta: Default::default(), + }); + let gid = match add_response.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }, + other => panic!("unexpected addMetalink result: {other:?}"), + }; + let uris = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetUris.as_str().to_owned(), + params: vec![RpcValue::String(gid)], + meta: Default::default(), + }); + let selected_uri = match uris.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(entry)) => match entry.get("uri") { + Some(RpcValue::String(uri)) => uri.clone(), + other => panic!("unexpected uri field payload: {other:?}"), + }, + other => panic!("unexpected getUris first entry: {other:?}"), + }, + other => panic!("unexpected getUris response: {other:?}"), + }; + + assert_eq!( + selected_uri, expected_uri, + "dispatcher should honor protocol-layer preferred resource selection" + ); +} + +#[test] +fn xmlrpc_add_metalink_roundtrip_and_dispatch_preserve_preferred_resource_semantics() { + let metalink_xml = r#" + + +http://fallback.example.org/metalink-xmlrpc.bin +https://preferred.example.org/metalink-xmlrpc.bin + +"#; + let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate( + &parse_metalink_document(metalink_xml).expect("fixture metalink should parse"), + ) + .map(|(_, resource)| resource.url.clone()) + .expect("fixture should expose a preferred resource"); + let call_xml = format!( + "aria2.addMetalink{}", + metalink_xml + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + ); + let parsed_call = xmlrpc_method_call_from_xml(&call_xml).expect("xmlrpc methodCall parse"); + let canonical_xml = xmlrpc_method_call_to_xml(&parsed_call); + let reparsed_call = + xmlrpc_method_call_from_xml(&canonical_xml).expect("canonical xmlrpc should parse"); + assert!( + canonical_xml.contains("aria2.addMetalink") + && canonical_xml.contains("metalink-xmlrpc.bin") + && reparsed_call.method_name == "aria2.addMetalink", + "xmlrpc methodCall should roundtrip cleanly for raw transport" + ); + + let mut dispatcher = InProcessRpcDispatcher::new(); + let version_response = dispatcher.dispatch_xml( + xmlrpc_method_call_from_xml( + "aria2.getVersion", + ) + .expect("xmlrpc getVersion call should parse"), + ); + let response_xml = xmlrpc_method_response_to_xml(&version_response); + assert!( + response_xml.contains("") + && response_xml.contains(aria2_rust_pro_compat::VERSION), + "xmlrpc methodResponse should be renderable for raw transport" + ); + + let add_response = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddMetalink.as_str().to_owned(), + params: vec![RpcValue::String(metalink_xml.to_owned())], + meta: Default::default(), + }); + let gid = match add_response.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::String(gid)) => gid.clone(), + other => panic!("unexpected addMetalink gid entry: {other:?}"), + }, + other => panic!("unexpected addMetalink result: {other:?}"), + }; + let uris = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetUris.as_str().to_owned(), + params: vec![RpcValue::String(gid)], + meta: Default::default(), + }); + let selected_uri = match uris.result { + Some(RpcValue::Array(entries)) => match entries.first() { + Some(RpcValue::Object(entry)) => match entry.get("uri") { + Some(RpcValue::String(uri)) => uri.clone(), + other => panic!("unexpected uri field payload: {other:?}"), + }, + other => panic!("unexpected getUris first entry: {other:?}"), + }, + other => panic!("unexpected getUris response: {other:?}"), + }; + assert_eq!( + selected_uri, expected_uri, + "protocol-layer preferred-resource selection should stay consistent across XML-RPC parse/render and JSON-RPC dispatch" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_add_metalink_expand_same_actionable_file_count() { + let metalink_xml = r#" + + +https://example.org/alpha.bin + + + + + +https://example.org/beta.bin + +"#; + + let mut json_dispatcher = InProcessRpcDispatcher::new(); + let json_result = rpc_result(json_dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddMetalink, + vec![RpcValue::String(metalink_xml.to_owned())], + ))); + + let mut xml_dispatcher = InProcessRpcDispatcher::new(); + let xml_result = rpc_result_from_xml(xml_dispatcher.dispatch_xml(xmlrpc_request( + "aria2.addMetalink", + vec![RpcValue::String(metalink_xml.to_owned())], + ))); + + let json_count = match json_result { + RpcValue::Array(items) => items.len(), + other => panic!("unexpected json addMetalink payload: {other:?}"), + }; + let xml_count = match xml_result { + RpcValue::Array(items) => items.len(), + other => panic!("unexpected xml addMetalink payload: {other:?}"), + }; + + assert_eq!(json_count, 2); + assert_eq!(xml_count, json_count); +} + +#[test] +fn jsonrpc_save_session_writes_storage_compatible_session_file() { + let session_root = std::env::temp_dir().join(format!( + "aria2-rust-pro-tests-session-root-{}", + std::process::id() + )); + let _ = std::fs::create_dir_all(&session_root); + let session_path = session_root.join("session.txt"); + let mut dispatcher = InProcessRpcDispatcher::with_runtime( + RuntimeConfig::default().with_session_path(session_path.clone()), + ); + 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( + "https://session.example.org/session-download.bin".to_owned(), + )], + meta: Default::default(), + }); + let added_gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + let save = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2SaveSession.as_str().to_owned(), + params: vec![], + meta: Default::default(), + }); + assert_eq!( + save.result, + Some(RpcValue::String("OK".to_owned())), + "saveSession should return OK for writable target" + ); + + let loaded = load_session_file(&session_path).expect("saved session file should load"); + assert!( + loaded.entries.iter().any(|entry| entry.gid == added_gid + && entry + .uris + .iter() + .any(|uri| uri.contains("session-download.bin"))), + "saved session should be readable by storage crate and contain addUri payload" + ); + let _ = std::fs::remove_file(session_path); + let _ = std::fs::remove_dir_all(session_root); +} diff --git a/crates/aria2-rust-pro-tests/src/tests/rpc_parity.rs b/crates/aria2-rust-pro-tests/src/tests/rpc_parity.rs new file mode 100644 index 0000000..452728e --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/rpc_parity.rs @@ -0,0 +1,306 @@ +use super::*; + +#[test] +fn xmlrpc_bt_status_roundtrip_preserves_new_bt_runtime_fields() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + let status_value = dispatcher + .dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid)], + )) + .result + .expect("tellStatus should produce a bt status payload"); + let response_xml = xmlrpc_method_response_to_xml(&XmlRpcMethodResponse { + value: Some(rpc_value_to_xmlrpc(status_value)), + fault: None, + meta: Default::default(), + }); + assert!(response_xml.contains("isBt")); + assert!(response_xml.contains("announceList")); + assert!(response_xml.contains("bitfield")); + assert!(response_xml.contains("shareRatio")); + assert!(response_xml.contains("magnetUri")); + + let reparsed = xmlrpc_method_response_from_xml(&response_xml) + .expect("rendered xmlrpc response should roundtrip"); + let rerendered = xmlrpc_method_response_to_xml(&reparsed); + assert!(rerendered.contains("isBt")); + assert!(rerendered.contains("seeder")); + assert!(rerendered.contains("numSeeders")); +} + +#[test] +fn parsed_jsonrpc_change_option_request_shape_matches_manual_dispatch_state() { + let mut parsed_dispatcher = InProcessRpcDispatcher::new(); + let mut manual_dispatcher = InProcessRpcDispatcher::new(); + let parsed_gid = match parsed_dispatcher + .dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String( + "https://example.org/shape-parsed.iso".to_owned(), + )], + )) + .result + { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected parsed addUri result: {other:?}"), + }; + let manual_gid = match manual_dispatcher + .dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String( + "https://example.org/shape-manual.iso".to_owned(), + )], + )) + .result + { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected manual addUri result: {other:?}"), + }; + + let request_json = format!( + r#"{{"jsonrpc":"2.0","id":"lane-f-shape","method":"aria2.changeOption","params":["{parsed_gid}",{{"split":8,"out":"shape.bin"}}]}}"# + ); + let parsed_request = + jsonrpc_request_from_json(&request_json).expect("request JSON should parse"); + let _ = parsed_dispatcher.dispatch_json(parsed_request); + let _ = manual_dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(manual_gid.clone()), + RpcValue::Object(BTreeMap::from([ + ("out".to_owned(), RpcValue::String("shape.bin".to_owned())), + ("split".to_owned(), RpcValue::Number(8)), + ])), + ], + )); + + let parsed_option_payload = rpc_object_result(parsed_dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(parsed_gid)], + ))); + let manual_option_payload = rpc_object_result(manual_dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(manual_gid)], + ))); + + assert_eq!( + parsed_option_payload, manual_option_payload, + "raw JSON-RPC request parsing should preserve the same downstream option state as a manually constructed request" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_get_global_option_payloads_match_exactly() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let _ = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([ + ( + "max-connection-per-server".to_owned(), + RpcValue::String("32".to_owned()), + ), + ("retry-on-403".to_owned(), RpcValue::Bool(true)), + ( + "all-proxy-user".to_owned(), + RpcValue::String("proxy-user".to_owned()), + ), + ("ftp-pasv".to_owned(), RpcValue::Bool(false)), + ]))], + )); + + let json_payload = rpc_result( + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2GetGlobalOption, Vec::new())), + ); + let xml_payload = rpc_result_from_xml( + dispatcher.dispatch_xml(xmlrpc_request("aria2.getGlobalOption", Vec::new())), + ); + + assert_eq!( + xml_payload, json_payload, + "XML-RPC should expose the same global option object as JSON-RPC" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_get_option_payloads_match_for_proxy_and_ftp_surface() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = match dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::Array(vec![RpcValue::String( + "https://example.org/file.iso".to_owned(), + )])], + )) { + JsonRpcResponse { + result: Some(RpcValue::String(gid)), + .. + } => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + let _ = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeGlobalOption, + vec![RpcValue::Object(BTreeMap::from([( + "all-proxy-user".to_owned(), + RpcValue::String("global-proxy-user".to_owned()), + )]))], + )); + let _ = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([ + ( + "ftp-proxy-user".to_owned(), + RpcValue::String("ftp-user".to_owned()), + ), + ("ftp-pasv".to_owned(), RpcValue::Bool(false)), + ])), + ], + )); + + let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + ))); + let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request( + "aria2.getOption", + vec![RpcValue::String(gid)], + ))); + + assert_eq!( + xml_payload, json_payload, + "XML-RPC should expose the same per-download option object as JSON-RPC for proxy/FTP options" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_tell_status_filtered_bt_payloads_match_after_runtime_updates() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + dispatcher + .apply_bt_runtime_tick(&gid, 32_768, 0, 512, 0, 0, 0, false, Some(6)) + .expect("download tick should complete the torrent payload"); + dispatcher + .set_bt_seeding_state(&gid, true, Some(1_000)) + .expect("completed torrent should enter seeding"); + dispatcher + .tick_bt_runtime_clock(&gid, 1_045, true) + .expect("share clock should advance"); + dispatcher + .apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(9)) + .expect("upload tick should enrich bt runtime fields"); + + let selected_keys = vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + RpcValue::String("completedLength".to_owned()), + RpcValue::String("uploadLength".to_owned()), + RpcValue::String("connections".to_owned()), + RpcValue::String("files".to_owned()), + RpcValue::String("bitfield".to_owned()), + RpcValue::String("isBt".to_owned()), + RpcValue::String("metadataOnly".to_owned()), + RpcValue::String("magnetUri".to_owned()), + RpcValue::String("announceList".to_owned()), + RpcValue::String("shareRatio".to_owned()), + RpcValue::String("shareTime".to_owned()), + RpcValue::String("seeder".to_owned()), + RpcValue::String("numSeeders".to_owned()), + RpcValue::String("completedPieces".to_owned()), + ]; + + let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Array(selected_keys.clone()), + ], + ))); + let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request( + "aria2.tellStatus", + vec![RpcValue::String(gid), RpcValue::Array(selected_keys)], + ))); + + assert_eq!( + xml_payload, json_payload, + "filtered BT tellStatus payloads should stay semantically identical across XML-RPC and JSON-RPC" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_multicall_nested_payloads_match_semantically() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + let method_specs = RpcValue::Array(vec![ + RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("aria2.getVersion".to_owned()), + ), + ("params".to_owned(), RpcValue::Array(Vec::new())), + ])), + RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("aria2.getSessionInfo".to_owned()), + ), + ("params".to_owned(), RpcValue::Array(Vec::new())), + ])), + RpcValue::Object(BTreeMap::from([ + ( + "methodName".to_owned(), + RpcValue::String("aria2.tellStatus".to_owned()), + ), + ( + "params".to_owned(), + RpcValue::Array(vec![ + RpcValue::String(gid), + RpcValue::Array(vec![ + RpcValue::String("gid".to_owned()), + RpcValue::String("status".to_owned()), + RpcValue::String("isBt".to_owned()), + RpcValue::String("magnetUri".to_owned()), + ]), + ]), + ), + ])), + ]); + + let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::SystemMulticall, + vec![method_specs.clone()], + ))); + let xml_payload = rpc_result_from_xml( + dispatcher.dispatch_xml(xmlrpc_request("system.multicall", vec![method_specs])), + ); + + assert_eq!( + xml_payload, json_payload, + "nested multicall results should preserve the same payload structure across RPC front doors" + ); +} + +#[test] +fn xmlrpc_and_jsonrpc_invalid_gid_errors_share_underlying_rpc_error() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = "bad-option-gid".to_owned(); + let json_error = dispatcher + .dispatch_json(rpc_request( + RpcMethod::Aria2GetOption, + vec![RpcValue::String(gid.clone())], + )) + .error + .expect("invalid gid should return a JSON-RPC error"); + let xml_fault = dispatcher + .dispatch_xml(xmlrpc_request( + "aria2.getOption", + vec![RpcValue::String(gid)], + )) + .fault + .expect("invalid gid should return an XML-RPC fault"); + + assert_eq!(xml_fault.code, 1); + assert_eq!(xml_fault.message, json_error.message); + assert_eq!(xml_fault.error, Some(json_error)); +} diff --git a/crates/aria2-rust-pro-tests/src/tests/rpc_pressure_and_runtime.rs b/crates/aria2-rust-pro-tests/src/tests/rpc_pressure_and_runtime.rs new file mode 100644 index 0000000..837d486 --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/rpc_pressure_and_runtime.rs @@ -0,0 +1,617 @@ +use super::*; + +#[test] +fn rpc_bt_status_pressure_smoke_keeps_responses_healthy() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let mut gids = Vec::new(); + for i in 0..32 { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=bt-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 1 + ); + 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(magnet)], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result in pressure smoke: {other:?}"), + }; + gids.push(gid); + } + + let mut ok_responses = 0usize; + for _round in 0..4 { + 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: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + ok_responses += 1; + } + other => panic!("unexpected tellStatus payload in pressure smoke: {other:?}"), + } + } + } + assert_eq!(ok_responses, gids.len() * 4); +} + +#[test] +fn rpc_bt_pressure_smoke_keeps_true_seeding_fields_consistent() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let torrent_add = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())], + meta: Default::default(), + }); + let torrent_gid = match torrent_add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result in pressure smoke: {other:?}"), + }; + + let mut background_gids = Vec::new(); + for i in 0..24 { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=bt-live-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 101 + ); + 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(magnet)], + meta: Default::default(), + }); + match add.result { + Some(RpcValue::String(gid)) => background_gids.push(gid), + other => panic!("unexpected addUri result in live pressure smoke: {other:?}"), + } + } + + let mut observed_share_times = Vec::new(); + let mut torrent_seed_states = Vec::new(); + for round in 0..4 { + for gid in std::iter::once(&torrent_gid).chain(background_gids.iter()) { + 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: Default::default(), + }); + let payload = match status.result { + Some(RpcValue::Object(payload)) => payload, + other => { + panic!("unexpected tellStatus payload in live pressure smoke: {other:?}") + } + }; + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + + if gid == &torrent_gid { + let seeder = match payload.get("seeder") { + Some(RpcValue::String(value)) => value == "true", + other => { + panic!("unexpected seeder field in live pressure smoke: {other:?}") + } + }; + torrent_seed_states.push(seeder); + + let share_time = match payload.get("shareTime") { + Some(RpcValue::String(value)) => { + value.parse::().expect("shareTime should parse") + } + other => { + panic!("unexpected shareTime field in live pressure smoke: {other:?}") + } + }; + observed_share_times.push(share_time); + + if round >= 2 { + assert!( + seeder, + "torrent should be a local seeder once payload is complete" + ); + assert_eq!( + payload.get("shareRatio"), + Some(&RpcValue::String("0.500".to_owned())) + ); + assert_eq!( + payload.get("uploadSpeed"), + Some(&RpcValue::String("180".to_owned())) + ); + } else if round == 0 { + assert!( + !seeder, + "torrent should not claim local seeding before any seeding runtime has started" + ); + } + } + } + + match round { + 0 => { + dispatcher + .set_bt_seeding_state(&torrent_gid, true, Some(1_000)) + .expect("should start seeding runtime state before completion"); + dispatcher + .tick_bt_runtime_clock(&torrent_gid, 1_020, true) + .expect("should advance runtime clock before completion"); + } + 1 => { + dispatcher + .apply_bt_runtime_tick( + &torrent_gid, + 32_768, + 16_384, + 90, + 180, + 5, + 5, + true, + Some(16), + ) + .expect("should complete payload and expose true seeding"); + } + 2 => { + dispatcher + .tick_bt_runtime_clock(&torrent_gid, 1_040, true) + .expect("should keep advancing share clock after completion"); + } + _ => {} + } + } + + assert_eq!(observed_share_times[0], 0); + assert!(torrent_seed_states[0..1].iter().all(|state| !state)); + assert!(torrent_seed_states[2..].iter().all(|state| *state)); + assert!( + observed_share_times + .windows(2) + .all(|window| window[1] >= window[0]), + "shareTime should be monotonic after seeding becomes visible: {observed_share_times:?}" + ); + assert_eq!(observed_share_times[1], 20); + assert_eq!(observed_share_times[2], 25); + assert_eq!(observed_share_times[3], 45); +} + +#[test] +fn rpc_bt_pressure_guard_keeps_status_active_and_global_stat_responsive() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let mut gids = Vec::new(); + for i in 0..64 { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 10_001 + ); + 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(magnet)], + meta: Default::default(), + }); + match add.result { + Some(RpcValue::String(gid)) => gids.push(gid), + other => panic!("unexpected addUri result in BT pressure guard: {other:?}"), + } + } + + let mut tell_status_calls = 0usize; + let mut tell_status_millis = 0_u128; + let mut tell_active_millis = 0_u128; + let mut tell_global_stat_millis = 0_u128; + + for round in 0..4 { + if let Some(first_gid) = gids.first() { + dispatcher + .apply_bt_runtime_tick( + first_gid, + 0, + 0, + 200 + round * 10, + 100 + round * 10, + 0, + 0, + false, + Some(8), + ) + .expect("pressure guard runtime tick should succeed"); + } + + let tell_status_start = Instant::now(); + 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: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + tell_status_calls += 1; + } + other => { + panic!("unexpected tellStatus payload in BT pressure guard: {other:?}") + } + } + } + tell_status_millis += tell_status_start.elapsed().as_millis(); + + let tell_active_start = Instant::now(); + let active = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2TellActive.as_str().to_owned(), + params: Vec::new(), + meta: Default::default(), + }); + match active.result { + Some(RpcValue::Array(items)) => { + assert!( + items.iter().all(|item| matches!(item, RpcValue::Object(_))), + "tellActive should keep returning object rows under BT-like pressure" + ); + } + other => panic!("unexpected tellActive payload in BT pressure guard: {other:?}"), + } + tell_active_millis += tell_active_start.elapsed().as_millis(); + + let tell_global_stat_start = Instant::now(); + let global = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2TellGlobalStat.as_str().to_owned(), + params: Vec::new(), + meta: Default::default(), + }); + match global.result { + Some(RpcValue::Object(payload)) => { + assert!(payload.contains_key("numActive")); + assert!(payload.contains_key("downloadSpeed")); + } + other => { + panic!("unexpected tellGlobalStat payload in BT pressure guard: {other:?}") + } + } + tell_global_stat_millis += tell_global_stat_start.elapsed().as_millis(); + } + + assert_eq!(tell_status_calls, gids.len() * 4); + assert!( + tell_status_millis <= 2_000, + "synthetic tellStatus pressure guard regressed badly: {tell_status_millis}ms for {tell_status_calls} calls" + ); + assert!( + tell_active_millis <= 500, + "synthetic tellActive pressure guard regressed badly: {tell_active_millis}ms" + ); + assert!( + tell_global_stat_millis <= 500, + "synthetic tellGlobalStat pressure guard regressed badly: {tell_global_stat_millis}ms" + ); +} + +#[test] +fn rpc_bt_mixed_pressure_guard_covers_churned_status_files_and_global_views() { + const TASK_COUNT: usize = 96; + const ROUNDS: usize = 6; + const FILE_SAMPLE_STRIDE: usize = 8; + + let mut dispatcher = InProcessRpcDispatcher::new(); + let mut gids = Vec::new(); + for i in 0..TASK_COUNT { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=mixed-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 30_001 + ); + let add = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(magnet)], + )); + match add.result { + Some(RpcValue::String(gid)) => gids.push(gid), + other => { + panic!("unexpected addUri result in mixed BT pressure guard: {other:?}") + } + } + } + + let mut tell_status_calls = 0usize; + let mut get_files_calls = 0usize; + let mut tell_active_calls = 0usize; + let mut tell_global_stat_calls = 0usize; + let mut mixed_millis = 0_u128; + + for round in 0..ROUNDS { + let round_start = Instant::now(); + for (index, gid) in gids.iter().enumerate() { + dispatcher + .apply_bt_runtime_tick( + gid, + u64::try_from(index + round).unwrap_or(u64::MAX), + u64::from(index % 3 == 0), + 128 + u64::try_from(round).unwrap_or_default(), + 64 + u64::try_from(index % 17).unwrap_or_default(), + u64::from(index % 5 == 0), + u64::from(index % 7 == 0), + index % 11 == 0, + Some(2 + u32::try_from(index % 9).unwrap_or_default()), + ) + .expect("mixed pressure guard runtime tick should succeed"); + + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(status.contains_key("status")); + assert!(status.contains_key("completedLength")); + assert!(status.contains_key("connections")); + tell_status_calls += 1; + } + + for gid in gids + .iter() + .skip(round % FILE_SAMPLE_STRIDE) + .step_by(FILE_SAMPLE_STRIDE) + { + let files = rpc_array_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + ))); + match files.first() { + Some(RpcValue::Object(file)) => { + assert!(file.contains_key("selected")); + assert!(file.contains_key("completedLength")); + assert!(file.contains_key("bitfield")); + } + other => { + panic!("unexpected getFiles payload in mixed pressure guard: {other:?}") + } + } + get_files_calls += 1; + } + + let active = rpc_array_result( + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellActive, Vec::new())), + ); + assert!( + active + .iter() + .all(|item| matches!(item, RpcValue::Object(_))), + "tellActive should keep object rows under mixed BT pressure" + ); + tell_active_calls += 1; + + let global = rpc_object_result( + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())), + ); + assert!(global.contains_key("numActive")); + assert!(global.contains_key("downloadSpeed")); + assert!(global.contains_key("uploadSpeed")); + tell_global_stat_calls += 1; + + mixed_millis += round_start.elapsed().as_millis(); + } + + assert_eq!(tell_status_calls, TASK_COUNT * ROUNDS); + assert_eq!(get_files_calls, (TASK_COUNT / FILE_SAMPLE_STRIDE) * ROUNDS); + assert_eq!(tell_active_calls, ROUNDS); + assert_eq!(tell_global_stat_calls, ROUNDS); + assert!( + mixed_millis <= 4_000, + "synthetic mixed BT RPC pressure guard regressed badly: {mixed_millis}ms for {tell_status_calls} tellStatus, {get_files_calls} getFiles, {tell_active_calls} tellActive, and {tell_global_stat_calls} tellGlobalStat calls" + ); +} + +#[test] +fn shared_runtime_speed_caps_rebalance_after_one_download_completes() { + let mut dispatcher = InProcessRpcDispatcher::with_runtime(RuntimeConfig { + max_overall_download_limit: Some(1_200), + max_overall_upload_limit: Some(600), + ..RuntimeConfig::default() + }); + + let gids = vec![ + add_magnet(&mut dispatcher, 70_001), + add_magnet(&mut dispatcher, 70_002), + add_magnet(&mut dispatcher, 70_003), + ]; + + let change_first = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gids[0].clone()), + RpcValue::Object(BTreeMap::from([ + ( + "max-download-limit".to_owned(), + RpcValue::String("250".to_owned()), + ), + ( + "max-upload-limit".to_owned(), + RpcValue::String("120".to_owned()), + ), + ])), + ], + )); + assert!( + change_first.error.is_none(), + "changeOption should succeed for the constrained fairness gid" + ); + + for gid in &gids { + dispatcher + .apply_bt_runtime_tick(gid, 128, 64, 5_000, 2_000, 0, 0, false, Some(6)) + .expect("initial fairness runtime tick should succeed"); + } + + let initial_expected = [(250_u64, 120_u64), (400, 200), (400, 200)]; + for (gid, (expected_download_speed, expected_upload_speed)) in gids.iter().zip(initial_expected) + { + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!( + rpc_u64_field(&status, "downloadSpeed"), + expected_download_speed + ); + assert_eq!(rpc_u64_field(&status, "uploadSpeed"), expected_upload_speed); + assert_eq!(rpc_u64_field(&status, "completedLength"), 128); + } + + let initial_global = rpc_object_result( + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())), + ); + assert_eq!(rpc_u64_field(&initial_global, "downloadSpeed"), 1_050); + assert_eq!(rpc_u64_field(&initial_global, "uploadSpeed"), 520); + + dispatcher + .mark_complete(&gids[0]) + .expect("completing the constrained gid should succeed"); + + for gid in &gids[1..] { + dispatcher + .apply_bt_runtime_tick(gid, 256, 96, 5_000, 2_000, 0, 0, false, Some(6)) + .expect("post-completion fairness runtime tick should succeed"); + } + + let completed_status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gids[0].clone())], + ))); + assert_eq!( + completed_status.get("status"), + Some(&RpcValue::String("complete".to_owned())) + ); + + for gid in &gids[1..] { + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(rpc_u64_field(&status, "downloadSpeed"), 600); + assert_eq!(rpc_u64_field(&status, "uploadSpeed"), 300); + assert_eq!(rpc_u64_field(&status, "completedLength"), 384); + } + + let rebalanced_global = rpc_object_result( + dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())), + ); + assert_eq!(rpc_u64_field(&rebalanced_global, "downloadSpeed"), 1_200); + assert_eq!(rpc_u64_field(&rebalanced_global, "uploadSpeed"), 600); +} + +#[test] + +fn bt_live_pressure_status_regression_tracks_true_seeding_runtime_over_repeated_rpc_probes() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = add_torrent(&mut dispatcher); + + let bootstrap = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(bootstrap.completed_length, 0); + assert_eq!(bootstrap.share_time, 0); + assert_eq!(bootstrap.share_ratio, "0.000"); + assert!(!bootstrap.seeder); + + dispatcher + .apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(2)) + .expect("first runtime tick should advance partial payload progress"); + let partial_a = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(partial_a.completed_length, 8_192); + assert_eq!(partial_a.share_time, 0); + assert_eq!(partial_a.share_ratio, "0.000"); + assert!(!partial_a.seeder); + assert_eq!(partial_a.connections, 2); + + dispatcher + .apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(3)) + .expect("second runtime tick should continue partial payload progress"); + let partial_b = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(partial_b.completed_length, 16_384); + assert_eq!(partial_b.share_time, 0); + assert_eq!(partial_b.share_ratio, "0.000"); + assert!(!partial_b.seeder); + assert_eq!(partial_b.connections, 3); + + dispatcher + .apply_bt_runtime_tick(&gid, 16_384, 0, 512, 0, 0, 0, false, Some(4)) + .expect("final download tick should complete payload without forcing seeding"); + let completed = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(completed.completed_length, 32_768); + assert_eq!(completed.share_time, 0); + assert_eq!(completed.share_ratio, "0.000"); + assert!( + !completed.seeder, + "seeder must stay false until the payload is complete and seeding state flips" + ); + assert_eq!(completed.connections, 4); + + dispatcher + .set_bt_seeding_state(&gid, true, Some(1_000)) + .expect("payload-complete bt group should enter seeding"); + let seeding_started = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(seeding_started.completed_length, 32_768); + assert_eq!(seeding_started.share_time, 0); + assert_eq!(seeding_started.share_ratio, "0.000"); + assert!( + seeding_started.seeder, + "seeder should only flip after payload completion once seeding begins" + ); + + dispatcher + .tick_bt_runtime_clock(&gid, 1_040, true) + .expect("share clock should advance under repeated rpc probing"); + let seeded_40 = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(seeded_40.completed_length, 32_768); + assert_eq!( + seeded_40.share_time, 40, + "shareTime should reflect the live seeding runtime after 40 seconds" + ); + assert_eq!(seeded_40.share_ratio, "0.000"); + assert!(seeded_40.seeder); + + dispatcher + .apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(16)) + .expect("upload tick should populate live share ratio after true payload completion"); + let seeded_45 = bt_status_probe(&mut dispatcher, &gid); + assert_eq!(seeded_45.completed_length, 32_768); + assert_eq!( + seeded_45.share_time, 45, + "shareTime should remain monotonic as runtime ticks continue" + ); + assert_eq!( + seeded_45.share_ratio, "0.500", + "shareRatio should become meaningful after true payload completion and upload runtime" + ); + assert!(seeded_45.seeder); + assert_eq!(seeded_45.connections, 16); + + let observed = [ + bootstrap.share_time, + partial_a.share_time, + partial_b.share_time, + completed.share_time, + seeding_started.share_time, + seeded_40.share_time, + seeded_45.share_time, + ]; + assert!( + observed.windows(2).all(|window| window[0] <= window[1]), + "shareTime should stay monotonic across repeated rpc probes: {observed:?}" + ); +} diff --git a/crates/aria2-rust-pro-tests/src/tests/support.rs b/crates/aria2-rust-pro-tests/src/tests/support.rs new file mode 100644 index 0000000..4b1941a --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/support.rs @@ -0,0 +1,340 @@ +pub(super) use std::{collections::BTreeMap, sync::Mutex, time::Instant}; + +pub(super) use aria2_rust_pro_compat::{ + BASELINE_COMMIT, is_required_pro_option, is_required_protocol, +}; +pub(super) use aria2_rust_pro_core::{GoalProgress, RuntimeConfig}; +pub(super) use aria2_rust_pro_protocol::{ + ChecksumSpec, DhtMessageModel, DhtNodeModel, DhtTransport, HttpCompletionState, + HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestTrackerTransport, + ResponseBody, TorrentPeerModel, TrackerPeerListModel, TrackerRequestModel, + TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel, TrackerTransport, + parse_metalink_document, + torrent::{ + DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireHandshakeModel, + PeerWireMessageKind, PeerWirePieceBlockModel, TorrentMessageModel, + }, + transport::{ + PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, + TransportEndpoint, TransportError, TransportScheme, + }, +}; +pub(super) use aria2_rust_pro_rpc::{ + InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, RpcMethod, RpcValue, XmlRpcMethodCall, + XmlRpcMethodResponse, XmlRpcParam, is_required_rpc_method, jsonrpc_request_from_json, + rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml, + xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc, +}; +pub(super) use aria2_rust_pro_storage::{ + ByteSink, ControlFileVersion, ObservedByteSink, load_session_file, +}; + +pub(super) const BT_TORRENT_FIXTURE: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + +pub(super) fn rpc_request(method: RpcMethod, params: Vec) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.as_str().to_owned(), + params, + meta: Default::default(), + } +} + +pub(super) fn xmlrpc_request(method_name: &str, params: Vec) -> XmlRpcMethodCall { + XmlRpcMethodCall { + method_name: method_name.to_owned(), + params: params + .into_iter() + .map(|value| XmlRpcParam { + value: rpc_value_to_xmlrpc(value), + }) + .collect(), + meta: Default::default(), + } +} + +pub(super) fn rpc_result(response: JsonRpcResponse) -> RpcValue { + match response.result { + Some(result) => result, + None => panic!("unexpected rpc result envelope without result: {response:?}"), + } +} + +pub(super) fn rpc_result_from_xml(response: XmlRpcMethodResponse) -> RpcValue { + match response { + XmlRpcMethodResponse { + value: Some(value), + fault: None, + .. + } => xmlrpc_value_to_rpc(value), + other => panic!("unexpected xmlrpc success envelope: {other:?}"), + } +} + +pub(super) fn add_torrent(dispatcher: &mut InProcessRpcDispatcher) -> String { + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())], + )); + match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + } +} + +pub(super) fn rpc_object_result(response: JsonRpcResponse) -> BTreeMap { + match response.result { + Some(RpcValue::Object(payload)) => payload, + other => panic!("unexpected rpc object result: {other:?}"), + } +} + +pub(super) fn rpc_array_result(response: JsonRpcResponse) -> Vec { + match response.result { + Some(RpcValue::Array(entries)) => entries, + other => panic!("unexpected rpc array result: {other:?}"), + } +} + +pub(super) fn rpc_string_field(payload: &BTreeMap, field: &str) -> String { + match payload.get(field) { + Some(RpcValue::String(value)) => value.clone(), + other => panic!("unexpected string field {field}: {other:?}"), + } +} + +pub(super) fn rpc_u64_field(payload: &BTreeMap, field: &str) -> u64 { + match payload.get(field) { + Some(RpcValue::String(value)) => value + .parse() + .unwrap_or_else(|error| panic!("unexpected u64 string field {field}: {error}")), + Some(RpcValue::Number(value)) => (*value) + .try_into() + .unwrap_or_else(|_| panic!("unexpected negative number field {field}: {value}")), + other => panic!("unexpected u64 field {field}: {other:?}"), + } +} + +pub(super) fn rpc_bool_field(payload: &BTreeMap, field: &str) -> bool { + match payload.get(field) { + Some(RpcValue::Bool(value)) => *value, + Some(RpcValue::String(value)) => value + .parse() + .unwrap_or_else(|error| panic!("unexpected bool string field {field}: {error}")), + other => panic!("unexpected bool field {field}: {other:?}"), + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) struct BtStatusProbe { + pub(super) completed_length: u64, + pub(super) share_time: u64, + pub(super) share_ratio: String, + pub(super) seeder: bool, + pub(super) connections: u64, +} + +pub(super) fn bt_status_probe(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> BtStatusProbe { + let status = rpc_object_result(dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.to_owned())], + ))); + BtStatusProbe { + completed_length: rpc_u64_field(&status, "completedLength"), + share_time: rpc_u64_field(&status, "shareTime"), + share_ratio: rpc_string_field(&status, "shareRatio"), + seeder: rpc_bool_field(&status, "seeder"), + connections: rpc_u64_field(&status, "connections"), + } +} + +pub(super) fn add_magnet(dispatcher: &mut InProcessRpcDispatcher, suffix: u64) -> String { + let magnet = format!( + "magnet:?xt=urn:btih:{suffix:040x}&dn=fairness-{suffix}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce" + ); + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(magnet)], + )); + match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result in fairness setup: {other:?}"), + } +} + +pub(super) fn decode_hex_20(raw: &str) -> [u8; 20] { + let bytes = (0..raw.len()) + .step_by(2) + .map(|offset| u8::from_str_radix(&raw[offset..offset + 2], 16)) + .collect::, _>>() + .expect("info hash should be valid hex"); + bytes.try_into().expect("info hash should be 20 bytes") +} + +pub(super) fn peer_wire_handshake_and_frames( + info_hash: [u8; 20], + peer_id: [u8; 20], + frames: &[PeerWireMessageKind], +) -> Vec { + let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize(); + for frame in frames { + bytes.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(frame.clone()) + .serialize_peer_wire_frame() + .expect("peer-wire frame should serialize"), + ); + } + bytes +} + +#[derive(Debug)] +pub(super) struct FakePeerWireConnector { + pub(super) response_payload: Vec, + pub(super) seen: Mutex>, +} + +impl FakePeerWireConnector { + pub(super) fn new(response_payload: Vec) -> Self { + Self { + response_payload, + seen: Mutex::new(Vec::new()), + } + } + + pub(super) fn seen(&self) -> Vec { + self.seen + .lock() + .expect("peer-wire seen requests mutex should not be poisoned") + .clone() + } +} + +impl PeerWireTransportConnector for FakePeerWireConnector { + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + self.seen + .lock() + .expect("peer-wire seen requests mutex should not be poisoned") + .push(request.clone()); + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: request.endpoint.address.clone(), + }, + payload: self.response_payload.clone(), + }) + } +} + +#[derive(Debug)] +pub(super) struct FixedPeerWireConnector { + pub(super) response_payload: Vec, +} + +impl PeerWireTransportConnector for FixedPeerWireConnector { + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: request.endpoint.address.clone(), + }, + payload: self.response_payload.clone(), + }) + } +} + +pub(super) struct FixedDhtTransport { + pub(super) response: DhtMessageModel, +} + +impl DhtTransport for FixedDhtTransport { + fn send_message( + &self, + _node: &DhtNodeModel, + _message: &DhtMessageModel, + ) -> Result { + Ok(self.response.clone()) + } +} + +#[derive(Debug)] +pub(super) struct RecordingDhtTransport { + pub(super) response: DhtMessageModel, + pub(super) seen: Mutex>, +} + +impl RecordingDhtTransport { + pub(super) fn new(response: DhtMessageModel) -> Self { + Self { + response, + seen: Mutex::new(Vec::new()), + } + } + + pub(super) fn seen(&self) -> Vec<(String, DhtMessageModel)> { + self.seen.lock().expect("dht seen mutex").clone() + } +} + +impl DhtTransport for RecordingDhtTransport { + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + self.seen + .lock() + .expect("dht seen mutex") + .push((format!("{}:{}", node.address, node.port), message.clone())); + Ok(self.response.clone()) + } +} + +pub(super) fn peer_wire_payload( + info_hash: [u8; 20], + peer_id: [u8; 20], + frames: &[PeerWireMessageKind], +) -> Vec { + let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize(); + for frame in frames { + bytes.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(frame.clone()) + .serialize_peer_wire_frame() + .expect("peer-wire frame should serialize"), + ); + } + bytes +} + +pub(super) fn info_hash_bytes(input: &str) -> [u8; 20] { + let encoded = input + .split("xt=urn:btih:") + .nth(1) + .and_then(|rest| rest.split('&').next()) + .unwrap_or(input); + let mut out = [0_u8; 20]; + for (index, chunk) in encoded.as_bytes().chunks_exact(2).enumerate() { + let hex = std::str::from_utf8(chunk).expect("info hash should stay utf8 hex"); + out[index] = u8::from_str_radix(hex, 16).expect("info hash should decode from hex"); + } + out +} + +pub(super) fn compact_peer(ip: [u8; 4], port: u16) -> Vec { + let mut out = ip.to_vec(); + out.extend_from_slice(&port.to_be_bytes()); + out +} + +pub(super) fn compact_node(node_tag: u8, ip: [u8; 4], port: u16) -> Vec { + let mut out = vec![node_tag; 20]; + out.extend_from_slice(&ip); + out.extend_from_slice(&port.to_be_bytes()); + out +} diff --git a/crates/aria2-rust-pro-tests/src/tests/tracker_and_surface_regression.rs b/crates/aria2-rust-pro-tests/src/tests/tracker_and_surface_regression.rs new file mode 100644 index 0000000..4c892ee --- /dev/null +++ b/crates/aria2-rust-pro-tests/src/tests/tracker_and_surface_regression.rs @@ -0,0 +1,236 @@ +use super::*; + +#[test] +fn magnet_with_tracker_tiers_and_dht_hint_preserves_bt_status_shape() { + let magnet = "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht-tiered.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result: {other:?}"), + }; + + 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: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(matches!( + payload.get("announceList"), + Some(RpcValue::Array(tiers)) if !tiers.is_empty() + )); + assert!(matches!( + payload.get("magnetUri"), + Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:") + )); + assert!( + payload.contains_key("numSeeders"), + "compat bt status should expose numSeeders key" + ); + } + other => panic!("unexpected tellStatus payload for tiered magnet: {other:?}"), + } + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid)], + meta: Default::default(), + }); + assert!( + matches!(peers.result, Some(RpcValue::Array(_))), + "getPeers should remain type-stable for dht/tiered magnet surface" + ); +} + +#[test] +fn tracker_announce_reingest_replaces_peer_view_with_latest_snapshot() { + fn bencode_int(value: i64) -> Vec { + format!("i{value}e").into_bytes() + } + fn bencode_bytes(value: &[u8]) -> Vec { + let mut out = format!("{}:", value.len()).into_bytes(); + out.extend_from_slice(value); + out + } + fn bencode_list(values: Vec>) -> Vec { + let mut out = vec![b'l']; + for value in values { + out.extend_from_slice(&value); + } + out.push(b'e'); + out + } + fn bencode_dict(entries: Vec<(&str, Vec)>) -> Vec { + let mut out = vec![b'd']; + for (key, value) in entries { + out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes()); + out.extend_from_slice(&value); + } + out.push(b'e'); + out + } + + let magnet = "magnet:?xt=urn:btih:8899aabbccddeeff00112233445566778899aabb&dn=bt-reannounce.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; + let mut dispatcher = InProcessRpcDispatcher::new(); + 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(magnet.to_owned())], + meta: Default::default(), + }); + let gid = match add.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri result: {other:?}"), + }; + + let announce_a = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![ + ("interval", bencode_int(1200)), + ( + "peers", + bencode_list(vec![bencode_dict(vec![ + ("ip", bencode_bytes(b"198.51.100.41")), + ("port", bencode_int(6001)), + ])]), + ), + ])) + .expect("announce-a should parse"); + dispatcher + .apply_tracker_announce_result(&gid, &announce_a) + .expect("announce-a should ingest"); + + let announce_b = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![ + ("interval", bencode_int(900)), + ( + "peers", + bencode_list(vec![ + bencode_dict(vec![ + ("ip", bencode_bytes(b"198.51.100.42")), + ("port", bencode_int(6002)), + ]), + bencode_dict(vec![ + ("ip", bencode_bytes(b"198.51.100.43")), + ("port", bencode_int(6003)), + ]), + ]), + ), + ])) + .expect("announce-b should parse"); + dispatcher + .apply_tracker_announce_result(&gid, &announce_b) + .expect("announce-b should ingest"); + + let peers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetPeers.as_str().to_owned(), + params: vec![RpcValue::String(gid)], + meta: Default::default(), + }); + match peers.result { + Some(RpcValue::Array(items)) => { + assert_eq!( + items.len(), + 2, + "second announce snapshot should be reflected" + ); + let ports = items + .into_iter() + .filter_map(|item| match item { + RpcValue::Object(peer) => peer.get("port").cloned(), + _ => None, + }) + .collect::>(); + assert!(ports.contains(&RpcValue::String("6002".to_owned()))); + assert!(ports.contains(&RpcValue::String("6003".to_owned()))); + } + other => panic!("unexpected getPeers payload after reannounce: {other:?}"), + } +} + +#[test] +fn bt_mixed_rpc_surface_smoke_preserves_peer_and_server_type_contracts() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; + let add_torrent = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), + params: vec![RpcValue::String(torrent_payload.to_owned())], + meta: Default::default(), + }); + let torrent_gid = match add_torrent.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + }; + + let mut magnet_gids = Vec::new(); + for i in 0..20 { + let magnet = format!( + "magnet:?xt=urn:btih:{:040x}&dn=bt-mixed-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", + i + 4000 + ); + 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(magnet)], + meta: Default::default(), + }); + match add.result { + Some(RpcValue::String(gid)) => magnet_gids.push(gid), + other => panic!("unexpected addUri result in mixed smoke: {other:?}"), + } + } + + let mut bt_status_objects = 0usize; + for gid in magnet_gids.iter().chain(std::iter::once(&torrent_gid)) { + 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: Default::default(), + }); + match status.result { + Some(RpcValue::Object(payload)) => { + assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); + assert!(payload.contains_key("status")); + assert!(payload.contains_key("completedLength")); + bt_status_objects += 1; + } + other => panic!("unexpected tellStatus payload in mixed smoke: {other:?}"), + } + + let servers = dispatcher.dispatch_json(JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: RpcMethod::Aria2GetServers.as_str().to_owned(), + params: vec![RpcValue::String(gid.clone())], + meta: Default::default(), + }); + let error = servers + .error + .expect("getServers should reject non-active BT downloads"); + assert!( + error + .message + .contains(&format!("No active download for GID#{gid}")) + ); + } + + assert_eq!(bt_status_objects, magnet_gids.len() + 1); +} diff --git a/crates/aria2-rust-pro-tests/test_support/support.rs b/crates/aria2-rust-pro-tests/test_support/support.rs new file mode 100644 index 0000000..1aa0c11 --- /dev/null +++ b/crates/aria2-rust-pro-tests/test_support/support.rs @@ -0,0 +1,300 @@ +#![doc(hidden)] +#![expect( + dead_code, + unreachable_pub, + reason = "shared integration-test support intentionally exposes a superset of helpers because each integration suite only consumes part of it" +)] + +use aria2_rust_pro_cli as _; +use aria2_rust_pro_compat as _; +use aria2_rust_pro_core as _; +use aria2_rust_pro_storage as _; +use aria2_rust_pro_tests as _; +use criterion as _; + +use std::{ + collections::BTreeMap, + io::{Read, Write}, + net::TcpListener, + path::Path, + sync::Mutex, + thread, +}; + +use aria2_rust_pro_protocol::{ + DhtMessageModel, DhtNodeModel, DhtTransport, PeerWireTransportConnector, + PeerWireTransportRequest, PeerWireTransportResponse, ReqwestTrackerTransport, + TorrentMessageModel, + torrent::{PeerWireHandshakeModel, PeerWireMessageKind}, + transport::{TransportEndpoint, TransportError, TransportScheme}, +}; +use aria2_rust_pro_rpc::{ + InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, RpcMeta, RpcMethod, RpcValue, +}; + +pub const BT_TORRENT_FIXTURE_BASE64: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; +pub const BT_TORRENT_FIXTURE_BYTES: &[u8] = b"d8:announce35:http://tracker.example.org/announce4:infod4:name10:ubuntu.iso12:piece lengthi16384e6:lengthi32768e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee"; + +pub fn write_torrent_fixture(path: &Path) { + std::fs::write(path, BT_TORRENT_FIXTURE_BYTES).expect("torrent fixture should write"); +} + +pub fn rpc_request(method: RpcMethod, params: Vec) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_owned()), + id: None, + method: method.as_str().to_owned(), + params, + meta: RpcMeta::default(), + } +} + +pub fn rpc_result_object(response: JsonRpcResponse) -> BTreeMap { + match response.result { + Some(RpcValue::Object(payload)) => payload, + other => panic!("unexpected object rpc result: {other:?}"), + } +} + +pub fn rpc_result_array(response: JsonRpcResponse) -> Vec { + match response.result { + Some(RpcValue::Array(payload)) => payload, + other => panic!("unexpected array rpc result: {other:?}"), + } +} + +pub fn rpc_string_field(payload: &BTreeMap, field: &str) -> String { + match payload.get(field) { + Some(RpcValue::String(value)) => value.clone(), + other => panic!("unexpected string field {field}: {other:?}"), + } +} + +pub fn rpc_u64_field(payload: &BTreeMap, field: &str) -> u64 { + match payload.get(field) { + Some(RpcValue::String(value)) => value + .parse() + .unwrap_or_else(|error| panic!("unexpected u64 string field {field}: {error}")), + Some(RpcValue::Number(value)) => (*value) + .try_into() + .unwrap_or_else(|_| panic!("unexpected negative number field {field}: {value}")), + other => panic!("unexpected u64 field {field}: {other:?}"), + } +} + +pub fn rpc_bool_field(payload: &BTreeMap, field: &str) -> bool { + match payload.get(field) { + Some(RpcValue::Bool(value)) => *value, + Some(RpcValue::String(value)) => value + .parse() + .unwrap_or_else(|error| panic!("unexpected bool string field {field}: {error}")), + other => panic!("unexpected bool field {field}: {other:?}"), + } +} + +pub fn add_torrent(dispatcher: &mut InProcessRpcDispatcher) -> String { + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddTorrent, + vec![RpcValue::String(BT_TORRENT_FIXTURE_BASE64.to_owned())], + )); + match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addTorrent result: {other:?}"), + } +} + +pub fn add_magnet(dispatcher: &mut InProcessRpcDispatcher, magnet: &str) -> String { + let response = dispatcher.dispatch_json(rpc_request( + RpcMethod::Aria2AddUri, + vec![RpcValue::String(magnet.to_owned())], + )); + match response.result { + Some(RpcValue::String(gid)) => gid, + other => panic!("unexpected addUri magnet result: {other:?}"), + } +} + +pub fn decode_hex_20(raw: &str) -> [u8; 20] { + let mut chunks = raw.as_bytes().chunks_exact(2); + let bytes = chunks + .by_ref() + .map(|chunk| { + let pair = std::str::from_utf8(chunk).expect("hex field should stay ascii"); + u8::from_str_radix(pair, 16) + }) + .collect::, _>>() + .expect("hex field should parse"); + assert!( + chunks.remainder().is_empty(), + "hex field should contain an even number of digits" + ); + bytes.try_into().expect("hex field should be 20 bytes") +} + +pub fn peer_wire_handshake_and_frames( + info_hash: [u8; 20], + peer_id: [u8; 20], + frames: &[PeerWireMessageKind], +) -> Vec { + let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize(); + for frame in frames { + bytes.extend_from_slice( + &TorrentMessageModel::from_peer_wire_kind(frame.clone()) + .serialize_peer_wire_frame() + .expect("peer-wire frame should serialize"), + ); + } + bytes +} + +pub fn compact_peer(address: [u8; 4], port: u16) -> Vec { + let mut bytes = address.to_vec(); + bytes.extend_from_slice(&port.to_be_bytes()); + bytes +} + +pub fn compact_node(node_id_byte: u8, address: [u8; 4], port: u16) -> Vec { + let mut bytes = vec![node_id_byte; 20]; + bytes.extend_from_slice(&address); + bytes.extend_from_slice(&port.to_be_bytes()); + bytes +} + +#[derive(Debug)] +pub struct RecordingDhtTransport { + response: DhtMessageModel, + seen: Mutex>, +} + +impl RecordingDhtTransport { + pub const fn new(response: DhtMessageModel) -> Self { + Self { + response, + seen: Mutex::new(Vec::new()), + } + } + + pub fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> { + self.seen + .lock() + .expect("dht seen mutex should not be poisoned") + .clone() + } +} + +impl DhtTransport for RecordingDhtTransport { + fn send_message( + &self, + node: &DhtNodeModel, + message: &DhtMessageModel, + ) -> Result { + self.seen + .lock() + .expect("dht seen mutex should not be poisoned") + .push((node.clone(), message.clone())); + Ok(self.response.clone()) + } +} + +#[derive(Debug)] +pub struct FakePeerWireConnector { + response_payload: Vec, + seen: Mutex>, +} + +impl FakePeerWireConnector { + pub const fn new(response_payload: Vec) -> Self { + Self { + response_payload, + seen: Mutex::new(Vec::new()), + } + } + + pub fn seen(&self) -> Vec { + self.seen + .lock() + .expect("peer-wire seen mutex should not be poisoned") + .clone() + } +} + +impl PeerWireTransportConnector for FakePeerWireConnector { + fn connect_peer_wire( + &self, + request: &PeerWireTransportRequest, + ) -> Result { + self.seen + .lock() + .expect("peer-wire seen mutex should not be poisoned") + .push(request.clone()); + Ok(PeerWireTransportResponse { + endpoint: TransportEndpoint { + scheme: TransportScheme::BitTorrent, + address: request.endpoint.address.clone(), + }, + payload: self.response_payload.clone(), + }) + } +} + +#[derive(Debug)] +pub struct LocalTrackerServer { + announce_url: String, + handle: Option>, +} + +impl LocalTrackerServer { + pub fn spawn(compact_peers: Vec, interval_secs: u64) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("tracker listener should bind"); + let addr = listener + .local_addr() + .expect("tracker listener should report local addr"); + let announce_url = format!("http://{addr}/announce"); + let handle = thread::spawn(move || { + let mut payload = format!( + "d8:intervali{interval_secs}e5:peers{}:", + compact_peers.len() + ) + .into_bytes(); + payload.extend_from_slice(&compact_peers); + payload.extend_from_slice(b"e"); + + let (mut stream, _) = listener.accept().expect("tracker client should connect"); + let mut request = [0_u8; 2048]; + let _ = stream + .read(&mut request) + .expect("tracker request should read"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + stream + .write_all(response.as_bytes()) + .expect("tracker response head should write"); + stream + .write_all(&payload) + .expect("tracker response body should write"); + }); + + Self { + announce_url, + handle: Some(handle), + } + } + + pub fn announce_url(&self) -> &str { + &self.announce_url + } +} + +impl Drop for LocalTrackerServer { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + handle.join().expect("tracker server thread should join"); + } + } +} + +pub fn tracker_transport() -> ReqwestTrackerTransport { + ReqwestTrackerTransport::new().expect("reqwest tracker transport should build") +} diff --git a/crates/aria2-rust-pro-tests/tests/bt_cli_runtime.rs b/crates/aria2-rust-pro-tests/tests/bt_cli_runtime.rs new file mode 100644 index 0000000..8503fee --- /dev/null +++ b/crates/aria2-rust-pro-tests/tests/bt_cli_runtime.rs @@ -0,0 +1,170 @@ +#![expect( + missing_docs, + reason = "integration test scenarios are documented by case names rather than item-level rustdoc" +)] + +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use aria2_rust_pro_cli::{ + BtStatusReport, Invocation, TransferSelection, execute_runtime, execute_runtime_with_downloader, +}; +use aria2_rust_pro_compat as _; +use aria2_rust_pro_core as _; +use aria2_rust_pro_protocol::FixtureHttpDownloader; +use aria2_rust_pro_storage as _; +use aria2_rust_pro_tests as _; +use criterion as _; + +#[path = "../test_support/support.rs"] +mod support; + +fn unique_temp_path(stem: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("aria2-rust-pro-tests-{stem}-{unique}")) +} + +fn write_config(path: &Path, download_dir: &Path) { + fs::write(path, format!("dir={}", download_dir.display())).expect("config should write"); +} + +fn assert_bt_snapshot( + bt: &BtStatusReport, + expected_metadata_only: bool, + expected_total_length: Option, +) { + assert_eq!(bt.is_bt, Some(true)); + assert_eq!(bt.metadata_only, Some(expected_metadata_only)); + 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!( + bt.announce_list_tier_count.unwrap_or_default() >= 1, + "bt snapshot should retain at least one announce tier" + ); + if !expected_metadata_only { + assert!( + bt.magnet_uri + .as_deref() + .is_some_and(|uri| uri.starts_with("magnet:?xt=urn:btih:")) + ); + assert_eq!(expected_total_length, Some(32_768)); + } +} + +#[test] +fn foreground_magnet_runtime_report_keeps_bt_snapshot_visible() { + let report = execute_runtime(Invocation::Run { + config_path: None, + uris: vec![String::from( + "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=dht-metadata.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker.example.org%3A6969", + )], + }) + .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.completed_download_count, 0); + assert_eq!(report.transfer_kinds, vec![TransferSelection::Magnet]); + assert_eq!(report.recognized_schemes, vec![String::from("magnet")]); + assert_eq!(report.first_total_length, Some(0)); + assert_eq!(report.first_completed_length, Some(0)); + + let bt = report + .first_bt_status + .as_ref() + .expect("magnet foreground execution should surface a bt snapshot"); + assert_bt_snapshot(bt, true, report.first_total_length); + assert_eq!(bt.num_seeders, Some(0)); +} + +#[test] +fn foreground_local_torrent_file_runs_as_bt_session_not_plain_uri() { + let temp_root = unique_temp_path("local-torrent"); + let download_dir = temp_root.join("downloads"); + let torrent_path = temp_root.join("fixture.torrent"); + let config_path = temp_root.join("aria2.conf"); + + fs::create_dir_all(&download_dir).expect("download directory should create"); + support::write_torrent_fixture(&torrent_path); + write_config(&config_path, &download_dir); + + let report = execute_runtime_with_downloader( + Invocation::Run { + config_path: Some(config_path), + uris: vec![torrent_path.to_string_lossy().into_owned()], + }, + &FixtureHttpDownloader::new(), + ) + .expect("runtime should accept a local torrent input"); + + assert_eq!(report.accepted_uri_count, 1); + assert_eq!(report.tracked_download_count, 1); + assert_eq!(report.completed_download_count, 0); + assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]); + assert_eq!(report.first_total_length, Some(32_768)); + assert_eq!(report.first_completed_length, Some(0)); + + let bt = report + .first_bt_status + .as_ref() + .expect("local torrent foreground execution should expose bt status"); + assert_bt_snapshot(bt, false, report.first_total_length); + + assert!( + !download_dir.join("fixture.torrent").exists(), + "torrent metainfo should bootstrap a bt session instead of being persisted as the final download artifact" + ); + + let _ = fs::remove_dir_all(&temp_root); +} + +#[test] +fn foreground_remote_torrent_url_bootstraps_bt_session_instead_of_saving_metainfo_payload() { + let temp_root = unique_temp_path("remote-torrent"); + let download_dir = temp_root.join("downloads"); + let config_path = temp_root.join("aria2.conf"); + fs::create_dir_all(&download_dir).expect("download directory should create"); + write_config(&config_path, &download_dir); + + let torrent_url = "https://tracker.example.org/files/ubuntu.torrent"; + let mut downloader = FixtureHttpDownloader::new(); + downloader.register(torrent_url, support::BT_TORRENT_FIXTURE_BYTES); + + let report = execute_runtime_with_downloader( + Invocation::Run { + config_path: Some(config_path), + uris: vec![torrent_url.to_owned()], + }, + &downloader, + ) + .expect("runtime should accept a remote torrent url"); + + assert_eq!(report.accepted_uri_count, 1); + assert_eq!(report.tracked_download_count, 1); + assert_eq!(report.completed_download_count, 0); + assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]); + assert_eq!(report.recognized_schemes, vec![String::from("https")]); + assert_eq!(report.first_total_length, Some(32_768)); + assert_eq!(report.first_completed_length, Some(0)); + + let bt = report + .first_bt_status + .as_ref() + .expect("remote torrent bootstrap should expose bt status"); + assert_bt_snapshot(bt, false, report.first_total_length); + + assert!( + !download_dir.join("ubuntu.torrent").exists(), + "remote torrent bootstrap should not leave the .torrent payload behind as the downloaded artifact" + ); + + let _ = fs::remove_dir_all(&temp_root); +} diff --git a/crates/aria2-rust-pro-tests/tests/bt_magnet_promotion.rs b/crates/aria2-rust-pro-tests/tests/bt_magnet_promotion.rs new file mode 100644 index 0000000..d83bc1d --- /dev/null +++ b/crates/aria2-rust-pro-tests/tests/bt_magnet_promotion.rs @@ -0,0 +1,168 @@ +#![expect( + missing_docs, + reason = "integration test scenarios are documented by case names rather than item-level rustdoc" +)] + +use std::collections::BTreeMap; + +use aria2_rust_pro_cli as _; +use aria2_rust_pro_compat as _; +use aria2_rust_pro_core as _; +use aria2_rust_pro_protocol::{ + DhtMessageModel, DhtNodeModel, + torrent::{PeerWireExtensionHandshakeModel, PeerWireMessageKind, PeerWireMetadataMessageModel}, +}; +use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcMethod, RpcValue}; +use aria2_rust_pro_storage as _; +use aria2_rust_pro_tests as _; +use criterion as _; + +#[path = "../test_support/support.rs"] +mod support; + +fn tell_status(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> BTreeMap { + support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.to_owned())], + ))) +} + +fn get_files(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> Vec { + support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.to_owned())], + ))) +} + +fn first_file<'a>(files: &'a [RpcValue], context: &str) -> &'a BTreeMap { + match files.first() { + Some(RpcValue::Object(file)) => file, + other => panic!("unexpected first file payload for {context}: {other:?}"), + } +} + +fn seed_peer_and_promote_metadata( + dispatcher: &mut InProcessRpcDispatcher, + gid: &str, + info_hash_hex: &str, +) { + dispatcher + .apply_dht_get_peers_result( + gid, + &DhtNodeModel { + node_id: String::new(), + address: "203.0.113.77".to_owned(), + port: 51413, + }, + &DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x55; 20], + Some(b"dht-token".to_vec()), + None, + vec![support::compact_peer([198, 51, 100, 42], 51415)], + ), + ) + .expect("dht get_peers should seed a connectable peer"); + + let connector = support::FakePeerWireConnector::new(support::peer_wire_handshake_and_frames( + support::decode_hex_20(info_hash_hex), + *b"-AZ2060-META-PROMO01", + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Extension( + PeerWireExtensionHandshakeModel { + extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]), + client_name: Some("libtorrent/2.0.11".to_owned()), + metadata_size: Some( + u32::try_from(support::BT_TORRENT_FIXTURE_BYTES.len()) + .expect("fixture metadata length should fit u32"), + ), + request_queue: Some(64), + } + .to_peer_wire_message(), + ), + PeerWireMessageKind::Extension( + PeerWireMetadataMessageModel::data( + 0, + u32::try_from(support::BT_TORRENT_FIXTURE_BYTES.len()) + .expect("fixture metadata length should fit u32"), + support::BT_TORRENT_FIXTURE_BYTES.to_vec(), + ) + .to_peer_wire_message(3), + ), + ], + )); + dispatcher + .execute_peer_wire_exchange(gid, &connector) + .expect("peer-wire metadata exchange should promote the magnet session"); +} + +#[test] +fn magnet_piece_observation_clears_metadata_only_and_pending_snapshot_flags() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let reference_gid = support::add_torrent(&mut dispatcher); + let reference_status = tell_status(&mut dispatcher, &reference_gid); + let magnet_uri = support::rpc_string_field(&reference_status, "magnetUri"); + let info_hash = support::rpc_string_field(&reference_status, "infoHash"); + + let gid = support::add_magnet(&mut dispatcher, &magnet_uri); + let before = tell_status(&mut dispatcher, &gid); + assert!(support::rpc_bool_field(&before, "metadataOnly")); + assert_eq!(support::rpc_string_field(&before, "magnetUri"), magnet_uri); + + let before_snapshot = dispatcher + .bt_runtime_coordinator_snapshot(&gid) + .expect("snapshot should inspect metadata-only magnet state"); + assert!(before_snapshot.metadata_only); + assert!(before_snapshot.metadata_exchange_pending); + + seed_peer_and_promote_metadata(&mut dispatcher, &gid, &info_hash); + + let after = tell_status(&mut dispatcher, &gid); + assert!(!support::rpc_bool_field(&after, "metadataOnly")); + assert_eq!(support::rpc_string_field(&after, "magnetUri"), magnet_uri); + + let after_snapshot = dispatcher + .bt_runtime_coordinator_snapshot(&gid) + .expect("snapshot should inspect promoted magnet state"); + assert!(!after_snapshot.metadata_only); + assert!(!after_snapshot.metadata_exchange_pending); +} + +#[test] +fn promoted_magnet_session_should_match_reference_torrent_file_surface() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let reference_gid = support::add_torrent(&mut dispatcher); + let reference_status = tell_status(&mut dispatcher, &reference_gid); + let reference_files = get_files(&mut dispatcher, &reference_gid); + let reference_file = first_file(&reference_files, "reference torrent"); + let magnet_uri = support::rpc_string_field(&reference_status, "magnetUri"); + let info_hash = support::rpc_string_field(&reference_status, "infoHash"); + + let gid = support::add_magnet(&mut dispatcher, &magnet_uri); + seed_peer_and_promote_metadata(&mut dispatcher, &gid, &info_hash); + + let promoted_status = tell_status(&mut dispatcher, &gid); + assert!( + !support::rpc_bool_field(&promoted_status, "metadataOnly"), + "metadata promotion should leave metadata-only mode before comparing the public torrent surface" + ); + assert_eq!( + support::rpc_u64_field(&promoted_status, "totalLength"), + support::rpc_u64_field(&reference_status, "totalLength"), + "promoted magnet sessions should hydrate the same totalLength visible on the equivalent .torrent bootstrap" + ); + + let promoted_files = get_files(&mut dispatcher, &gid); + let promoted_file = first_file(&promoted_files, "promoted magnet"); + assert_eq!( + promoted_file.get("path"), + reference_file.get("path"), + "promoted magnet sessions should expose the real torrent file path instead of a synthetic placeholder row" + ); + assert_eq!( + promoted_file.get("length"), + reference_file.get("length"), + "promoted magnet sessions should expose the same per-file length as the equivalent .torrent bootstrap" + ); +} diff --git a/crates/aria2-rust-pro-tests/tests/bt_orchestration.rs b/crates/aria2-rust-pro-tests/tests/bt_orchestration.rs new file mode 100644 index 0000000..c8cd493 --- /dev/null +++ b/crates/aria2-rust-pro-tests/tests/bt_orchestration.rs @@ -0,0 +1,317 @@ +#![expect( + missing_docs, + reason = "integration test scenarios are documented by case names rather than item-level rustdoc" +)] + +use std::collections::BTreeMap; + +use aria2_rust_pro_cli as _; +use aria2_rust_pro_compat as _; +use aria2_rust_pro_core as _; +use aria2_rust_pro_protocol::{ + DhtMessageModel, TrackerRequestModel, TrackerTransport, + torrent::{ + DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireMessageKind, + PeerWirePieceBlockModel, + }, +}; +use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcMethod, RpcValue}; +use aria2_rust_pro_storage as _; +use aria2_rust_pro_tests as _; +use criterion as _; + +#[path = "../test_support/support.rs"] +mod support; + +#[test] +#[expect( + clippy::too_many_lines, + reason = "integration scenario keeps one end-to-end bt surface in a single readable flow" +)] +fn tracker_dht_peer_wire_and_seeding_updates_remain_rpc_visible_across_public_dispatcher_api() { + let tracker_server = + support::LocalTrackerServer::spawn(support::compact_peer([198, 51, 100, 9], 51413), 900); + let tracker = support::tracker_transport(); + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = support::add_torrent(&mut dispatcher); + + let bootstrap_status = + support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + let info_hash = support::rpc_string_field(&bootstrap_status, "infoHash"); + let magnet_uri = support::rpc_string_field(&bootstrap_status, "magnetUri"); + let announce = tracker + .announce(&TrackerRequestModel { + announce_url: tracker_server.announce_url().to_owned(), + info_hash: info_hash.clone(), + peer_id: "0123456789abcdef0123456789abcdef01234567".to_owned(), + port: 6881, + uploaded: 0, + downloaded: 0, + left: 32_768, + event: Some("started".to_owned()), + compact: true, + numwant: Some(10), + }) + .expect("live tracker announce should succeed"); + dispatcher + .apply_tracker_announce_result(&gid, &announce) + .expect("tracker announce should update the bt view"); + + let dht_get_peers = support::RecordingDhtTransport::new(DhtMessageModel::get_peers_response( + b"gp".to_vec(), + vec![0x55; 20], + Some(b"dht-token".to_vec()), + Some(support::compact_node(0x44, [203, 0, 113, 99], 51414)), + vec![support::compact_peer([198, 51, 100, 10], 51415)], + )); + dispatcher + .execute_dht_get_peers(&gid, &dht_get_peers) + .expect("dht get_peers should succeed"); + + let dht_find_node = support::RecordingDhtTransport::new(DhtMessageModel::find_node_response( + b"fn".to_vec(), + vec![0x66; 20], + vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel { + node_id: [0x77; 20], + address: [203, 0, 113, 100], + port: 51416, + }], + )); + dispatcher + .execute_dht_find_node(&gid, &dht_find_node) + .expect("dht find_node should succeed"); + + let dht_announce = support::RecordingDhtTransport::new(DhtMessageModel::ping_response( + b"ap".to_vec(), + vec![0x88; 20], + )); + dispatcher + .execute_dht_announce_peer(&gid, &dht_announce) + .expect("dht announce_peer should succeed after token handoff"); + + let info_hash_bytes = support::decode_hex_20(&info_hash); + let remote_peer_id = *b"-AZ2060-MODERN-PEER!"; + let connector = support::FakePeerWireConnector::new(support::peer_wire_handshake_and_frames( + info_hash_bytes, + remote_peer_id, + &[ + PeerWireMessageKind::Unchoke, + PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, true])), + PeerWireMessageKind::Piece(PeerWirePieceBlockModel { + piece_index: 0, + block_offset: 0, + block: vec![0x5a; 16_384], + }), + ], + )); + dispatcher + .execute_peer_wire_exchange(&gid, &connector) + .expect("peer-wire exchange should succeed"); + + dispatcher + .apply_bt_runtime_tick(&gid, 16_384, 0, 512, 0, 0, 0, false, Some(8)) + .expect("runtime tick should finish the remaining payload"); + dispatcher + .set_bt_seeding_state(&gid, true, Some(1_000)) + .expect("completed torrent should enter seeding"); + dispatcher + .tick_bt_runtime_clock(&gid, 1_030, true) + .expect("share clock should advance"); + dispatcher + .apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(8)) + .expect("upload tick should populate visible share fields"); + let _ = dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2ChangeOption, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Object(BTreeMap::from([( + "select-file".to_owned(), + RpcValue::String("1".to_owned()), + )])), + ], + )); + + let status = support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2TellStatus, + vec![RpcValue::String(gid.clone())], + ))); + assert!(support::rpc_bool_field(&status, "isBt")); + assert!(!support::rpc_bool_field(&status, "metadataOnly")); + assert_eq!(support::rpc_string_field(&status, "magnetUri"), magnet_uri); + assert_eq!(support::rpc_u64_field(&status, "completedLength"), 32_768); + assert_eq!(support::rpc_u64_field(&status, "connections"), 8); + assert_eq!(support::rpc_string_field(&status, "shareRatio"), "0.500"); + assert_eq!(support::rpc_u64_field(&status, "shareTime"), 35); + assert!(support::rpc_bool_field(&status, "seeder")); + assert_eq!(support::rpc_u64_field(&status, "numSeeders"), 1); + + let files = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + ))); + match files.first() { + Some(RpcValue::Object(file)) => { + assert_eq!( + file.get("selected"), + Some(&RpcValue::String("true".to_owned())) + ); + assert_eq!( + file.get("path"), + Some(&RpcValue::String("ubuntu.iso".to_owned())) + ); + } + other => panic!("unexpected file payload after bt orchestration: {other:?}"), + } + + let peers = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2GetPeers, + vec![RpcValue::String(gid.clone())], + ))); + match peers.first() { + Some(RpcValue::Object(peer)) => { + assert_eq!( + peer.get("peerId"), + Some(&RpcValue::String( + "2d415a323036302d4d4f4445524e2d5045455221".to_owned() + )) + ); + assert_eq!( + peer.get("peerChoking"), + Some(&RpcValue::String("false".to_owned())) + ); + assert_eq!( + peer.get("seeder"), + Some(&RpcValue::String("true".to_owned())) + ); + } + other => panic!("unexpected peer payload after bt orchestration: {other:?}"), + } + + let seen_announce = dht_announce.seen(); + assert_eq!( + seen_announce.len(), + 1, + "announce_peer should send exactly one query" + ); + match &seen_announce + .first() + .expect("announce_peer should record one query") + .1 + .body + { + DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => { + assert_eq!(query.info_hash, info_hash_bytes); + assert_eq!(query.token, b"dht-token".to_vec()); + assert_eq!(query.port, 6881); + } + other => panic!("unexpected announce_peer query payload: {other:?}"), + } + + let seen_peer_wire = connector.seen(); + assert_eq!( + seen_peer_wire.len(), + 1, + "peer-wire transport should see one request" + ); + assert!( + seen_peer_wire + .first() + .expect("peer-wire transport should record one request") + .payload + .len() + > 68, + "peer-wire request should include the handshake plus follow-up frames" + ); +} + +#[test] +fn bt_status_surfaces_remain_monotonic_under_public_runtime_tick_pressure() { + let mut dispatcher = InProcessRpcDispatcher::new(); + let gid = support::add_torrent(&mut dispatcher); + let mut last_completed = 0_u64; + let mut last_share_time = 0_u64; + + for round in 0..48_u64 { + let downloaded_delta = if round < 16 { 2_048 } else { 0 }; + let uploaded_delta = if round >= 16 { 512 } else { 0 }; + let seeding = round >= 16; + dispatcher + .apply_bt_runtime_tick( + &gid, + downloaded_delta, + uploaded_delta, + 256 + round, + 128 + round, + u64::from(seeding), + u64::from(seeding), + seeding, + Some(2 + u32::try_from(round % 5).expect("round modulo 5 should fit u32")), + ) + .expect("runtime tick should succeed under repeated probing"); + if round == 16 { + dispatcher + .set_bt_seeding_state(&gid, true, Some(2_000)) + .expect("completed torrent should enter seeding"); + } + if seeding { + dispatcher + .tick_bt_runtime_clock(&gid, 2_000 + round, true) + .expect("runtime clock should advance while seeding"); + } + + let status = support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2TellStatus, + vec![ + RpcValue::String(gid.clone()), + RpcValue::Array(vec![ + RpcValue::String("status".to_owned()), + RpcValue::String("completedLength".to_owned()), + RpcValue::String("connections".to_owned()), + RpcValue::String("files".to_owned()), + RpcValue::String("bitfield".to_owned()), + RpcValue::String("isBt".to_owned()), + RpcValue::String("shareRatio".to_owned()), + RpcValue::String("shareTime".to_owned()), + RpcValue::String("seeder".to_owned()), + ]), + ], + ))); + let completed = support::rpc_u64_field(&status, "completedLength"); + let share_time = support::rpc_u64_field(&status, "shareTime"); + assert!(support::rpc_bool_field(&status, "isBt")); + assert!( + completed >= last_completed, + "completedLength should stay monotonic under repeated probes" + ); + assert!( + share_time >= last_share_time, + "shareTime should stay monotonic under repeated probes" + ); + assert!( + status.contains_key("bitfield"), + "bt tellStatus should retain bitfield visibility under pressure" + ); + assert!( + status.contains_key("files"), + "bt tellStatus should retain files visibility under pressure" + ); + last_completed = completed; + last_share_time = share_time; + + let files = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request( + RpcMethod::Aria2GetFiles, + vec![RpcValue::String(gid.clone())], + ))); + assert_eq!(files.len(), 1, "torrent fixture should expose one file"); + match files.first() { + Some(RpcValue::Object(file)) => { + assert!(file.contains_key("selected")); + assert!(file.contains_key("completedLength")); + } + other => panic!("unexpected getFiles payload under pressure: {other:?}"), + } + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..2ad3db5 --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +[graph] +targets = [ + { triple = "x86_64-pc-windows-msvc" }, + { triple = "x86_64-unknown-linux-gnu" }, +] +all-features = true + +[advisories] +version = 2 +db-path = "./target/cargo-deny-advisory-dbs" +yanked = "deny" +ignore = [] + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "BSD-3-Clause", + "CDLA-Permissive-2.0", + "GPL-2.0-or-later", + "ISC", + "MIT", + "Unicode-3.0", +] +exceptions = [] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..809662d --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,4 @@ +target +target-alt +.git +.cargo-home diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..4b599e2 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,13 @@ +PUID=1000 +PGID=1000 +UMASK_SET=022 +# Required. Set this to a strong private value before running docker compose. +RPC_SECRET= +RPC_BIND_ADDRESS=127.0.0.1 +RPC_PORT=6800 +LISTEN_PORT=6888 +DISK_CACHE=64M +IPV6_MODE=false +UPDATE_TRACKERS=false +CUSTOM_TRACKER_URL= +SPECIAL_MODE= diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..6117611 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,53 @@ +FROM rust:1-bookworm AS builder + +WORKDIR /work +COPY Cargo.toml Cargo.lock rust-toolchain.toml deny.toml ./ +COPY .cargo ./.cargo +COPY crates ./crates +COPY xtask ./xtask + +RUN rustup toolchain install nightly --profile minimal --component rust-src && \ + cargo +nightly build -Zbuild-std=std,panic_abort --release -p aria2-rust-pro-cli --bin aria2-rust-pro + +FROM debian:bookworm-slim + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + gosu \ + rclone && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /work/target/release/aria2-rust-pro /usr/local/bin/aria2-rust-pro +RUN ln -s /usr/local/bin/aria2-rust-pro /usr/local/bin/aria2c + +COPY docker/entrypoint.sh /usr/local/bin/aria2-rust-pro-entrypoint +COPY docker/defaults /defaults + +RUN chmod 755 /usr/local/bin/aria2-rust-pro-entrypoint /usr/local/bin/aria2-rust-pro && \ + find /defaults -type f -name '*.sh' -exec chmod 755 {} + && \ + mkdir -p /config /downloads /run/aria2-rust-pro + +ENV CONFIG_DIR=/config \ + DOWNLOAD_DIR=/downloads \ + PUID= \ + PGID= \ + UMASK_SET=022 \ + RPC_SECRET= \ + RPC_PORT=6800 \ + LISTEN_PORT=6888 \ + DISK_CACHE=64M \ + IPV6_MODE=false \ + UPDATE_TRACKERS=false \ + CUSTOM_TRACKER_URL= \ + SPECIAL_MODE= + +VOLUME ["/config", "/downloads"] + +EXPOSE 6800 6888 6888/udp + +ENTRYPOINT ["/usr/local/bin/aria2-rust-pro-entrypoint"] diff --git a/docker/defaults/aria2.conf b/docker/defaults/aria2.conf new file mode 100644 index 0000000..aa2d16b --- /dev/null +++ b/docker/defaults/aria2.conf @@ -0,0 +1,9 @@ +# Base config copied to /config/aria2.conf on first start. +# Environment-derived overrides are appended at runtime without mutating this file. + +continue=true +split=5 +max-connection-per-server=16 +min-split-size=1K +piece-length=1K +check-certificate=true diff --git a/docker/defaults/bt-tracker.txt b/docker/defaults/bt-tracker.txt new file mode 100644 index 0000000..14a0ad6 --- /dev/null +++ b/docker/defaults/bt-tracker.txt @@ -0,0 +1,3 @@ +udp://tracker.opentrackr.org:1337/announce +udp://tracker.torrent.eu.org:451/announce +http://tracker.opentrackr.org:1337/announce diff --git a/docker/defaults/script.conf b/docker/defaults/script.conf new file mode 100644 index 0000000..1636c05 --- /dev/null +++ b/docker/defaults/script.conf @@ -0,0 +1,5 @@ +download-dir=/downloads +completed-dir=/downloads/completed +upload-log=/config/upload.log +move-log=/config/move.log +dest-dir=/downloads/completed diff --git a/docker/defaults/script/clean.sh b/docker/defaults/script/clean.sh new file mode 100644 index 0000000..62e88e0 --- /dev/null +++ b/docker/defaults/script/clean.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +echo "[INFO] Clean hook completed." diff --git a/docker/defaults/script/delete.sh b/docker/defaults/script/delete.sh new file mode 100644 index 0000000..8238527 --- /dev/null +++ b/docker/defaults/script/delete.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +echo "[INFO] Delete hook completed." diff --git a/docker/defaults/script/move.sh b/docker/defaults/script/move.sh new file mode 100644 index 0000000..ffdb85e --- /dev/null +++ b/docker/defaults/script/move.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +config="/config/script.conf" +dest="/downloads/completed" + +if [ -f "${config}" ]; then + configured_dest="$(awk -F= '$1 == "dest-dir" {print substr($0, index($0, "=") + 1)}' "${config}" | tail -n 1)" + if [ -n "${configured_dest}" ]; then + dest="${configured_dest}" + fi +fi + +mkdir -p "${dest}" +echo "[INFO] Move hook completed. Destination: ${dest}" diff --git a/docker/defaults/script/rclone.env b/docker/defaults/script/rclone.env new file mode 100644 index 0000000..0f115a6 --- /dev/null +++ b/docker/defaults/script/rclone.env @@ -0,0 +1 @@ +RCLONE_DESTINATION=remote:downloads diff --git a/docker/defaults/script/tracker.sh b/docker/defaults/script/tracker.sh new file mode 100644 index 0000000..a9d772f --- /dev/null +++ b/docker/defaults/script/tracker.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +aria2_conf="${1:-/config/aria2.conf}" +tracker_url="${CUSTOM_TRACKER_URL:-https://trackerslist.com/all_aria2.txt}" +tmp_file="$(mktemp)" + +cleanup() { + rm -f "${tmp_file}" "${tmp_file}.conf" +} +trap cleanup EXIT + +echo "[INFO] Updating BT trackers from ${tracker_url}" + +if ! curl -fsSL --connect-timeout 10 --max-time 20 "${tracker_url}" -o "${tmp_file}"; then + echo "[WARN] Failed to download tracker list from ${tracker_url}" >&2 + exit 1 +fi + +trackers="$( + sed 's/\r$//' "${tmp_file}" | + awk 'NF {print}' | + paste -sd, - +)" + +if [ -z "${trackers}" ]; then + echo "[WARN] Tracker list is empty" >&2 + exit 1 +fi + +if grep -q '^bt-tracker=' "${aria2_conf}"; then + awk -v value="bt-tracker=${trackers}" ' + BEGIN { replaced = 0 } + /^bt-tracker=/ { print value; replaced = 1; next } + { print } + END { if (!replaced) print value } + ' "${aria2_conf}" > "${tmp_file}.conf" +else + cp "${aria2_conf}" "${tmp_file}.conf" + printf '\nbt-tracker=%s\n' "${trackers}" >> "${tmp_file}.conf" +fi + +cat "${tmp_file}.conf" > "${aria2_conf}" +echo "[INFO] Tracker file updated." diff --git a/docker/defaults/script/upload.sh b/docker/defaults/script/upload.sh new file mode 100644 index 0000000..8dab184 --- /dev/null +++ b/docker/defaults/script/upload.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +echo "[INFO] Rclone upload hook is enabled. Configure /config/rclone.conf and /config/script.conf for custom behavior." diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..a13261d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,27 @@ +services: + aria2-rust-pro: + build: + context: .. + dockerfile: docker/Dockerfile + image: aria2-rust-pro:local + container_name: aria2-rust-pro + restart: unless-stopped + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + UMASK_SET: ${UMASK_SET:-022} + RPC_SECRET: ${RPC_SECRET:?Set RPC_SECRET in docker/.env before exposing RPC} + RPC_PORT: ${RPC_PORT:-6800} + LISTEN_PORT: ${LISTEN_PORT:-6888} + DISK_CACHE: ${DISK_CACHE:-64M} + IPV6_MODE: ${IPV6_MODE:-false} + UPDATE_TRACKERS: ${UPDATE_TRACKERS:-false} + CUSTOM_TRACKER_URL: ${CUSTOM_TRACKER_URL:-} + SPECIAL_MODE: ${SPECIAL_MODE:-} + ports: + - "${RPC_BIND_ADDRESS:-127.0.0.1}:${RPC_PORT:-6800}:${RPC_PORT:-6800}" + - "${LISTEN_PORT:-6888}:${LISTEN_PORT:-6888}" + - "${LISTEN_PORT:-6888}:${LISTEN_PORT:-6888}/udp" + volumes: + - ../.local/docker/config:/config + - ../.local/docker/downloads:/downloads diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..4ee478b --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,176 @@ +#!/bin/sh +set -eu + +CONFIG_DIR="${CONFIG_DIR:-/config}" +DOWNLOAD_DIR="${DOWNLOAD_DIR:-/downloads}" +DEFAULTS_DIR="${DEFAULTS_DIR:-/defaults}" +RUNTIME_DIR="${RUNTIME_DIR:-/run/aria2-rust-pro}" +BASE_CONFIG="${CONFIG_DIR}/aria2.conf" +SESSION_FILE="${CONFIG_DIR}/aria2.session" +RUNTIME_CONFIG="${RUNTIME_DIR}/aria2.generated.conf" +ARIA2_BIN="${ARIA2_BIN:-/usr/local/bin/aria2c}" +SCRIPT_DIR="${CONFIG_DIR}/script" +SCRIPT_CONFIG="${CONFIG_DIR}/script.conf" +TRACKER_SNAPSHOT="${DEFAULTS_DIR}/bt-tracker.txt" +explicit_aria2_command=0 + +if [ "$#" -gt 0 ]; then + case "$1" in + aria2c|aria2-rust-pro) + explicit_aria2_command=1 + shift + ;; + -*) + ;; + *) + exec "$@" + ;; + esac +fi + +if [ "${explicit_aria2_command}" -eq 1 ] && [ "$#" -gt 0 ]; then + case "$1" in + --version|-v|--help|-h|--help=*) + exec "${ARIA2_BIN}" "$@" + ;; + esac +fi + +mkdir -p "${CONFIG_DIR}" "${DOWNLOAD_DIR}" "${RUNTIME_DIR}" + +if [ ! -f "${BASE_CONFIG}" ]; then + cp "${DEFAULTS_DIR}/aria2.conf" "${BASE_CONFIG}" +fi + +if [ ! -f "${SESSION_FILE}" ]; then + : > "${SESSION_FILE}" +fi + +copy_default_if_missing() { + src="$1" + dest="$2" + if [ ! -e "${dest}" ]; then + cp "${src}" "${dest}" + fi +} + +copy_mode_script() { + script_name="$1" + mkdir -p "${SCRIPT_DIR}" + copy_default_if_missing "${DEFAULTS_DIR}/script/${script_name}" "${SCRIPT_DIR}/${script_name}" + chmod 755 "${SCRIPT_DIR}/${script_name}" +} + +apply_bt_tracker_snapshot() { + if [ ! -f "${TRACKER_SNAPSHOT}" ]; then + return + fi + + bundled_trackers="$( + sed 's/\r$//' "${TRACKER_SNAPSHOT}" | + awk 'NF {print}' | + paste -sd, - + )" + + if [ -z "${bundled_trackers}" ]; then + return + fi + + if grep -Eq '^bt-tracker=.+$' "${RUNTIME_CONFIG}"; then + return + fi + + if grep -q '^bt-tracker=' "${RUNTIME_CONFIG}"; then + tmp_file="$(mktemp)" + awk -v value="bt-tracker=${bundled_trackers}" ' + BEGIN { replaced = 0 } + /^bt-tracker=/ { print value; replaced = 1; next } + { print } + END { if (!replaced) print value } + ' "${RUNTIME_CONFIG}" > "${tmp_file}" + cat "${tmp_file}" > "${RUNTIME_CONFIG}" + rm -f "${tmp_file}" + return + fi + + append_line "bt-tracker=${bundled_trackers}" +} + +configure_special_mode() { + mode="${SPECIAL_MODE:-}" + case "${mode}" in + "") + return + ;; + move) + copy_mode_script "move.sh" + copy_default_if_missing "${DEFAULTS_DIR}/script.conf" "${SCRIPT_CONFIG}" + append_line "on-download-complete=${SCRIPT_DIR}/move.sh" + ;; + rclone) + copy_mode_script "upload.sh" + copy_default_if_missing "${DEFAULTS_DIR}/script.conf" "${SCRIPT_CONFIG}" + copy_default_if_missing "${DEFAULTS_DIR}/script/rclone.env" "${CONFIG_DIR}/rclone.env" + if ! command -v rclone >/dev/null 2>&1; then + printf 'warning: SPECIAL_MODE=rclone was requested but the rclone binary is not available\n' >&2 + fi + append_line "on-download-complete=${SCRIPT_DIR}/upload.sh" + ;; + *) + printf 'warning: unknown SPECIAL_MODE "%s"; expected empty, move, or rclone\n' "${mode}" >&2 + ;; + esac +} + +append_line() { + printf '%s\n' "$1" >> "${RUNTIME_CONFIG}" +} + +cp "${BASE_CONFIG}" "${RUNTIME_CONFIG}" +append_line "" +append_line "# Generated overrides" +append_line "dir=${DOWNLOAD_DIR}" +append_line "input-file=${SESSION_FILE}" +append_line "save-session=${SESSION_FILE}" +append_line "save-session-interval=60" +append_line "enable-rpc=true" +append_line "rpc-listen-port=${RPC_PORT:-6800}" +append_line "listen-port=${LISTEN_PORT:-6888}" +append_line "dht-listen-port=${LISTEN_PORT:-6888}" +if [ "${IPV6_MODE:-false}" = "true" ] || [ "${IPV6_MODE:-false}" = "TRUE" ] || [ "${IPV6_MODE:-false}" = "1" ]; then + append_line "disable-ipv6=false" +else + append_line "disable-ipv6=true" +fi + +if [ -n "${DISK_CACHE:-}" ]; then + append_line "disk-cache=${DISK_CACHE}" +fi + +if [ -n "${RPC_SECRET:-}" ]; then + append_line "rpc-secret=${RPC_SECRET}" + append_line "rpc-listen-all=true" +else + printf 'warning: RPC_SECRET is empty; RPC stays loopback-only unless the base config overrides it\n' >&2 +fi + +apply_bt_tracker_snapshot +configure_special_mode + +if [ "${UPDATE_TRACKERS:-false}" = "true" ] || [ "${UPDATE_TRACKERS:-false}" = "TRUE" ] || [ "${UPDATE_TRACKERS:-false}" = "1" ]; then + if ! "${DEFAULTS_DIR}/script/tracker.sh" "${RUNTIME_CONFIG}"; then + printf 'warning: tracker update failed; continuing with bundled or existing tracker data\n' >&2 + fi +fi + +if [ -n "${PUID:-}" ] && [ -n "${PGID:-}" ]; then + chown -R "${PUID}:${PGID}" "${CONFIG_DIR}" "${DOWNLOAD_DIR}" "${RUNTIME_DIR}" +fi + +umask "${UMASK_SET:-022}" + +if [ -n "${PUID:-}" ] && [ -n "${PGID:-}" ]; then + exec gosu "${PUID}:${PGID}" "${ARIA2_BIN}" --enable-rpc --conf-path="${RUNTIME_CONFIG}" "$@" +fi + +exec "${ARIA2_BIN}" --enable-rpc --conf-path="${RUNTIME_CONFIG}" "$@" diff --git a/docs/architecture/adr/0001-rust-native-rewrite.md b/docs/architecture/adr/0001-rust-native-rewrite.md new file mode 100644 index 0000000..df7766b --- /dev/null +++ b/docs/architecture/adr/0001-rust-native-rewrite.md @@ -0,0 +1,19 @@ +# ADR 0001: Rust-Native Rewrite + +## Decision + +`aria2-rust-pro` is a Rust-native rewrite of the current C++ Pro Core behavior. +It does not wrap the C++ implementation and does not reuse the deleted previous +Rust rewrite. + +## Rationale + +The project needs a maintainable Rust 2024 codebase with strict testing and +Cargo-native quality gates while preserving external aria2 compatibility. + +## Consequences + +- Compatibility must be proven through golden outputs and behavior tests. +- Missing behavior must be tracked in `progress.md` and the compatibility + ledger. +- `unsafe` is forbidden unless a later ADR narrowly approves it. diff --git a/docs/compatibility/aria2-compat-ledger.md b/docs/compatibility/aria2-compat-ledger.md new file mode 100644 index 0000000..fcbdf3a --- /dev/null +++ b/docs/compatibility/aria2-compat-ledger.md @@ -0,0 +1,103 @@ +# aria2 Compatibility Ledger + +Status values: + +- `planned`: required but not implemented. +- `implemented`: implementation exists but is not fully verified. +- `verified`: behavior is covered by tests or golden evidence. +- `deferred`: intentionally delayed with a recorded reason. +- `approved-incompatible`: explicitly approved incompatibility. + +No row may remain `planned` for the 1.0 release. + +## Public Surfaces + +| Surface | Target | Status | Evidence | +| --- | --- | --- | --- | +| CLI executable | aria2-compatible command behavior | verified | `crates/aria2-rust-pro-cli/src/lib.rs` now exposes startup profile, command surface, config validation, runtime report models, aria2-shaped `--version` / `--help=#all` rendering, and real filtered `--help=` output while `docs/compatibility/goldens/cli/` captures the current release-binary output for both `--version` and `--help=#all`; `crates/aria2-rust-pro-compat/src/help.rs` has direct tests for the aria2-style version banner, full help, and filtered help | +| CLI options | original aria2 plus current Pro options | verified | normalized option registry in `crates/aria2-rust-pro-compat/src/options.rs` now exposes registry-backed CLI/config/RPC spelling helpers (`cli_spellings`, `config_spellings`, `lookup_spellings`), help placeholders, help synopsis generation, and a `live_option_specs()` view of the implemented surface, while `crates/aria2-rust-pro-cli/src/lib.rs` now directly proves long-form compat alias canonicalization (`http-want-digest` -> `no-want-digest-header`), bool/value option parsing, and representative live option preservation through `parse_cli()` | +| Config file | aria2 config syntax | verified | `crates/aria2-rust-pro-compat/src/config.rs` now directly covers config AST/document/profile/normalization, BOM handling, long-form and short-form compatibility alias canonicalization, strict unknown-option rejection, and normalized profile loading, while `crates/aria2-rust-pro-cli/src/lib.rs` proves file-backed config loading plus CLI-over-config precedence for representative runtime/session/transport options | +| JSON-RPC | aria2 JSON-RPC methods and fields | verified | method ledger plus `dispatcher/router/handlers/jsonrpc` module tree in `crates/aria2-rust-pro-rpc/src/` now have direct evidence for batch handling, routed id preservation, invalid-request/invalid-param shaping, success-payload rendering, effective default-plus-inherited option views, upstream-style per-download GID/state errors, transport-visible HTTP/WebSocket behavior, and JSON/XML parity across the covered method surface; `crates/aria2-rust-pro-rpc/src/dispatcher.rs` now additionally proves a raw JSON-RPC success request can parse, dispatch, and render a transport-visible success payload without leaking an `error` member | +| XML-RPC | original aria2 XML-RPC compatibility | verified | `crates/aria2-rust-pro-rpc/src/xmlrpc.rs` now has request/response/value/fault models, shared XML-RPC-to-RPC conversion, deterministic XML rendering helpers, and XML parsers for methodCall/methodResponse/fault; typed scalar parsing now also tolerates whitespace-padded `int` / `i4` / `i8` / `biginteger` / `boolean` / `double` / `dateTime.iso8601` / `base64` tags without regressing the earlier large-integer fallback behavior; `crates/aria2-rust-pro-rpc/src/dispatcher.rs` now routes ordinary XML-RPC methods through the real in-process dispatcher instead of stub handlers, so XML-RPC `tellStatus`, `getGlobalStat`, and other non-multicall methods reuse the same payload semantics as JSON-RPC; top-level XML-RPC faults now use upstream-style `faultCode=1` / `faultString`, XML-RPC `system.multicall` now emits upstream-style in-band fault structs instead of JSON-style `code` / `message` members, `crates/aria2-rust-pro-rpc/src/server.rs` now recognizes obvious XML-RPC `methodCall` bodies on normalized `/rpc` paths even when the client omits a helpful `Content-Type` and prefixes the payload with a BOM/comment prelude, and `crates/aria2-rust-pro-tests/src/lib.rs` now proves XML-RPC `methodCall` parse/render can drive `aria2.addMetalink` dispatch while still converging on the protocol-layer preferred resource candidate | +| Session files | aria2 session/input conventions | verified | `crates/aria2-rust-pro-storage/src/session.rs` session file and metadata state shell now has explicit coverage for mixed legacy/v2 session lines, multiple URIs, and escaped metadata while still preserving conservative resume/runtime extension keys via metadata maps; `crates/aria2-rust-pro-tests/src/lib.rs` now proves JSON-RPC `saveSession` output is readable by storage session loading, and `crates/aria2-rust-pro-core/src/engine.rs` now round-trips multi-URI downloads through session save/load without collapsing to one URI | +| Control files | `.aria2` resume behavior | verified | `crates/aria2-rust-pro-storage/src/control.rs` now auto-detects upstream binary `.aria2` files, reads both the legacy v0000 little-endian and v0001 network-order headers, reconstructs verified/in-flight piece state from the upstream bitfields, and writes an upstream-readable binary prefix plus a Rust trailer that preserves richer metadata such as retry counts and last-error state; the same file now carries fixture-backed binary-reader tests plus core session/control recovery coverage | +| HTTP/HTTPS | range, resume, retry, checksum | verified | `crates/aria2-rust-pro-protocol/src/http.rs` now computes inline-payload checksum verification in `completion_model()`, carries streamed observed-length/digest truth, models unsatisfied `Content-Range` responses for `416 Range Not Satisfiable`, and preserves clearer resume truth when a server ignores a requested range; `crates/aria2-rust-pro-protocol/src/downloader.rs` directly covers fixture/downloader-generated streamed responses, request-proxy routing with bypass rules, finer DNS/TLS/proxy failure mapping, fixture/live `416` parsing, live query emission, redirect-origin provenance, and negotiated HTTP version mapping; `crates/aria2-rust-pro-cli/src/lib.rs` and `crates/aria2-rust-pro-tests/src/lib.rs` together cover partial-range completion, retry, checksum-aware completion, and connector-backed runtime execution across the current HTTP/HTTPS surface | +| FTP | original aria2 FTP behavior | verified | `crates/aria2-rust-pro-protocol/src/ftp.rs` FTP model plus fixture-backed downloader support in `crates/aria2-rust-pro-protocol/src/downloader.rs`, CLI runtime execution wiring in `crates/aria2-rust-pro-cli/src/lib.rs`, and a local real FTP server smoke in CLI tests that exercises USER/PASS/PASV/RETR over real sockets | +| SFTP | original aria2 SFTP behavior | verified | `crates/aria2-rust-pro-protocol/src/sftp.rs` SFTP model plus fixture-backed downloader support in `crates/aria2-rust-pro-protocol/src/downloader.rs`, CLI runtime execution wiring in `crates/aria2-rust-pro-cli/src/lib.rs`, and a local live SFTP smoke using a Docker loopback server | +| Metalink | Metalink3/4 compatibility | verified | `crates/aria2-rust-pro-protocol/src/metalink.rs` now has a structured XML parser, preferred-resource selection helpers, and a shared multi-file download-plan surface, separates document-level `` from file-level ``, preserves CDATA-backed `` / `` payloads, normalizes checksum algorithms/values plus resource `location` / `lang` / `type` / `private` metadata, ignores piece-level hashes for file verification, and uses richer preferred-resource tie-breaking; `crates/aria2-rust-pro-cli/src/lib.rs` now resolves both local `.meta4` / `.metalink` files and remote HTTP/HTTPS Metalink documents through that shared plan before downloading every actionable file while injecting implied per-file `out` / `checksum` defaults into execution, `crates/aria2-rust-pro-rpc/src/dispatcher.rs` uses the same plan for `aria2.addMetalink` and returns one GID per actionable file, `crates/aria2-rust-pro-protocol/src/bt_metalink.rs` now delegates wrapper parsing to the real parser instead of returning a root-only stub document while preserving that richer normalized model, and `crates/aria2-rust-pro-tests/src/lib.rs` exercises both JSON-RPC and XML-RPC entrypoints for the same multi-file expansion semantics while CLI tests cover local and remote Metalink document execution | +| BitTorrent | torrent, magnet, DHT, tracker, seeding | verified | `crates/aria2-rust-pro-protocol/src/magnet.rs`, `torrent.rs`, `tracker.rs`, `transport.rs`, and `bt_metalink.rs` directly cover magnet parsing/serialization, torrent metadata/info-hash extraction, tracker announce/scrape parsing plus live HTTP tracker transport, UDP tracker codecs, peer-wire codecs, and typed DHT helpers; `crates/aria2-rust-pro-rpc/src/dispatcher.rs` now directly proves `.torrent` and magnet registration, tracker announce/scrape ingestion, peer-wire exchange, `ping` / `find_node` / `get_peers` / `announce_peer` runtime updates, `select-file`, BT share/seeding visibility, and metadata-only magnet promotion through BEP10/BEP9 `ut_metadata` negotiation into the same torrent-backed runtime surface used by `.torrent` bootstrap; `crates/aria2-rust-pro-cli/src/lib.rs` and `crates/aria2-rust-pro-tests/src/lib.rs` provide CLI-visible BT status/share snapshots, false-completion/select-file contracts, pressure smokes, mixed magnet/torrent status/getServers coverage, and the dedicated `bt_magnet_promotion` regression that locks the promotion path at the public surface | +| Docker env | current Pro Docker compatibility | verified | `docker/Dockerfile`, `docker/entrypoint.sh`, `docker/docker-compose.yml`, `docker/.env.example`, `xtask docker smoke`, and `xtask docker smoke-local` now provide a source-building container path with verified daemon-free and daemon-backed smokes; `scripts/docker/smoke.ps1` and `scripts/docker/smoke-local.ps1` are compatibility wrappers around those Cargo-native commands; the entrypoint now maps the legacy env names (`PUID`, `PGID`, `UMASK_SET`, `RPC_SECRET`, `RPC_PORT`, `LISTEN_PORT`, `DISK_CACHE`, `IPV6_MODE`, `UPDATE_TRACKERS`, `CUSTOM_TRACKER_URL`, `SPECIAL_MODE`) into the generated runtime config and bundled hook/tracker behavior, including seeded `bt-tracker`, tracker updates, and `move` / `rclone` completion hooks | + +Compatibility relock note: `crates/aria2-rust-pro-core/src/engine.rs` now rejects invalid `pause`/`unpause` state transitions instead of silently accepting them, and `crates/aria2-rust-pro-rpc/src/dispatcher.rs` now has targeted tests proving upstream-style `cannot be paused now` / `cannot be unpaused now` errors for real invalid states as well as missing GIDs. + +Compatibility relock note: public JSON-RPC methods that take a GID from param 0 now distinguish missing params from malformed GIDs, returning upstream-style `Invalid GID ...` application errors for bad hex input instead of collapsing those cases into `invalid_params` or `unknown_method`. + +Compatibility relock note: the invalid-GID regression matrix now explicitly covers `tellStatus`, `getOption`, `changeOption`, `getUris`, `getFiles`, `changeUri`, `changePosition`, `pause`, `unpause`, `remove`, and `removeDownloadResult`, so the shared dispatcher helper change is backed by method-level evidence instead of only a subset of entrypoints. + +Compatibility relock note: `aria2.changePosition` failures now use the upstream waiting-queue-specific wording `GID#... not found in the waiting queue.` instead of the earlier generic local `Could not change position...` message. + +Compatibility relock note: the BT/tracker/DHT helper entrypoints in `dispatcher.rs` now follow the same GID semantics as the public RPC layer, using `Invalid GID ...` for malformed hex and `No such download for GID#...` when the referenced download does not exist, instead of leaking `unknown_method`. + +Compatibility relock note: `aria2.changeUri` now matches the upstream mixed-array parsing behavior more closely by silently skipping non-string `delUris` / `addUris` entries while still applying delete-then-insert semantics to string members, and it now has explicit coverage for the upstream-style valid-but-missing-GID error `Cannot remove URIs from GID#...`. + +Compatibility relock note: across the currently covered per-download and BT helper paths, the repo now distinguishes the upstream-style failure buckets for missing params, malformed GIDs, nonexistent downloads, invalid `pause` / `unpause` states, waiting-queue misses, and valid-but-missing `changeUri` removals instead of collapsing them into generic local dispatcher errors. + +Compatibility relock note: the JSON-RPC transport now preserves broader upstream-style request-id shapes, including boolean and structured ids, treats malformed request shapes as synthetic invalid-request / invalid-params dispatches instead of transport parse aborts, and ignores non-object batch members while keeping shared JSON/XML error shaping aligned through `model.rs`, `router.rs`, `handlers.rs`, and `jsonrpc.rs`. + +Compatibility relock note: the XML-RPC surface now accepts a wider set of real-client request forms, including processing instructions, comments, whitespace-tolerant self-closing tags, empty value/container forms, `nil`, and decimal/hex numeric entities, while preserving shared fault semantics and avoiding lossy large-number truncation on RPC-to-XML rendering. + +Compatibility relock note: `crates/aria2-rust-pro-core/src/request.rs` now normalizes multi-URI request invariants by filtering empty URI entries, de-duplicating/repositioning repeated URIs, and repairing stale primary URI state at `RequestGroup::with_context`, reducing drift between `changeUri`, `getUris`, and session replay paths. + +## Option Ledger + +| Option | Source | Required behavior | Status | Evidence | +| --- | --- | --- | --- | --- | +| `dir` | Original | local file output directory option | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_DIR`) | +| `out` | Original | per-download output filename option | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_OUT`) | +| `split` | Original | split count option (default `5`) | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_SPLIT`) | +| `continue` | Original | resume partial download (`-c`) | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_CONTINUE`) | +| `enable-rpc` | Original | enable JSON-RPC/XML-RPC server | verified | `aria2/src/OptionHandlerFactory.cc`, `aria2/src/usage_text.h` | +| `rpc-listen-port` | Original | RPC listen port (default `6800`) | verified | `aria2/src/OptionHandlerFactory.cc`, `aria2/src/usage_text.h` | +| `listen-port` | Original | BT TCP/UDP listen port | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs`, `crates/aria2-rust-pro-cli/src/lib.rs`, `docker/entrypoint.sh`, `xtask docker smoke-local` | +| `dht-listen-port` | Original | DHT UDP listen port | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs`, `crates/aria2-rust-pro-cli/src/lib.rs`, `docker/entrypoint.sh`, `xtask docker smoke-local` | +| `disable-ipv6` | Original | disable IPv6 sockets and resolution | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs`, `crates/aria2-rust-pro-cli/src/lib.rs`, `docker/entrypoint.sh`, `xtask docker smoke-local` | +| `bt-save-metadata` | Original | save bt metadata during magnet flow | verified | `aria2/src/OptionHandlerFactory.cc`, `aria2/src/usage_text.h` | +| `follow-torrent` | Original | control how torrent/metalink references are followed | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_FOLLOW_TORRENT`) | +| `metalink-enable-unique-protocol` | Original | enforce unique protocol in metalink handling | verified | `aria2/src/OptionHandlerFactory.cc` (`PREF_METALINK_ENABLE_UNIQUE_PROTOCOL`) | +| `max-connection-per-server` | Pro | no 16-connection cap | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry | +| `split` | Original | per-download split count participates in HTTP connection budgeting and bounded follow-up range planning | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` plus runtime/task integration in `crates/aria2-rust-pro-cli/src/lib.rs` | +| `min-split-size` | Pro | minimum `1K` target in later phase behavior | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `piece-length` | Pro | minimum `1K` target in later phase behavior | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `retry-on-400` | Pro | retry only when enabled and `retry-wait > 0` | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `retry-on-403` | Pro | retry only when enabled and `retry-wait > 0` | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `retry-on-406` | Pro | retry only when enabled and `retry-wait > 0` | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `retry-on-unknown` | Pro | retry only when enabled and `retry-wait > 0` | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` option registry target | +| `no-want-digest-header` | Pro | canonical option for digest header behavior | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` alias-aware registry | +| `http-want-digest` | CompatibilityAlias | compatibility alias mapped to `no-want-digest-header` | implemented | `crates/aria2-rust-pro-compat/src/options/mod.rs` alias-aware registry | + +## Current Coverage + +| Area | Status | Evidence | +| --- | --- | --- | +| Core download/task model | implemented | `crates/aria2-rust-pro-core/src/engine.rs`, `request.rs`, `progress.rs`, `session.rs`, `scheduler.rs`; current Phase 3 core now carries concrete segment assignments in `RequestGroup` and scheduler-built byte ranges in `schedule_once()` | +| HTTP/HTTPS protocol surface | implemented | `crates/aria2-rust-pro-protocol/src/http.rs`, `downloader.rs`, `transport.rs`, `auth.rs` | +| FTP/SFTP fallback | implemented | `crates/aria2-rust-pro-protocol/src/ftp.rs` and `sftp.rs` model shells, plus fixture-backed downloader execution in `crates/aria2-rust-pro-protocol/src/downloader.rs` and runtime dispatch in `crates/aria2-rust-pro-cli/src/lib.rs` | +| Magnet parsing | implemented | `crates/aria2-rust-pro-protocol/src/magnet.rs` now has magnet URI parsing/serialization helpers and tests, while `bt_metalink.rs` keeps the compatibility wrapper aligned with the shared parser | +| Metalink parser facade | implemented | `crates/aria2-rust-pro-protocol/src/metalink.rs` now parses file/resource/checksum XML structure and exposes deterministic preferred-resource / first-actionable-candidate helpers, while `bt_metalink.rs` still provides compatibility bridge helpers | +| Torrent parser facade | implemented | `crates/aria2-rust-pro-protocol/src/torrent.rs` now parses torrent bencode metadata, derives raw-info SHA1 hashes, materializes file/piece/tracker models, builds tracker request metadata, and carries peer-wire handshake/message parse-serialize helpers; `crates/aria2-rust-pro-protocol/src/tracker.rs` now parses announce/scrape responses, builds encoded tracker URLs, and carries UDP tracker protocol helpers | +| RPC method ledger | implemented | `crates/aria2-rust-pro-rpc/src/methods.rs` | +| In-process RPC bridge | implemented | `crates/aria2-rust-pro-rpc/src/dispatcher.rs` and `handlers.rs` | +| Storage piece/control skeleton | implemented | `crates/aria2-rust-pro-storage/src/model.rs`, `control.rs`, `resume.rs`, `store.rs` | +| CLI-to-runtime bridge | implemented | `crates/aria2-rust-pro-cli/src/lib.rs` runtime/config/report bridge | + +## Phase 3 HTTP Range/Resume/Retry Notes (In Progress) + +| Topic | Current state | Status | Evidence | Next step | +| --- | --- | --- | --- | --- | +| Range request modeling | Range units/specs and request header surfaces are modeled | implemented | `crates/aria2-rust-pro-protocol/src/http.rs` (`RangeSpec`, `RangeUnit`, `HttpRequestModel`) | Bind modeled ranges to runtime piece/progress accounting for active transfers | +| Resume state persistence bridge | Session/control and resume model shells exist, including piece-state serialization surfaces, session metadata extension keys, request-level resume state, and control-file recovery of partial completed-length / retry-count state | implemented | `crates/aria2-rust-pro-storage/src/session.rs`, `crates/aria2-rust-pro-storage/src/control.rs`, `crates/aria2-rust-pro-storage/src/resume.rs`, `crates/aria2-rust-pro-core/src/request.rs`, `crates/aria2-rust-pro-core/src/engine.rs` | Extend from single-partial recovery into live segmented resume flows | +| Retry policy modeling | Retry policy/strategy types exist, runtime config carries retry toggles, aggregate retry counts surface through runtime/RPC state, and richer retry-attempt history is now retained across request/engine/RPC layers | implemented | `crates/aria2-rust-pro-protocol/src/http.rs` (`RetryPolicy`, `RetryStrategy`), `crates/aria2-rust-pro-core/src/request.rs`, `crates/aria2-rust-pro-core/src/runtime.rs`, `crates/aria2-rust-pro-rpc/src/dispatcher.rs` (`retryCount`, `retryAttempts`) | Persist richer retry-attempt history through more real session/control flows without overcommitting the public surface | +| Runtime transfer truth | Successful fixture HTTP transfers already feed length/completion/connection/retry-count state back into engine/RPC, control-file reload recovers partial progress when session metadata omits it, multi-step partial responses advance follow-up `Range` requests across segments, scheduler/session bridges now carry segment-plan, retry-history, active-segment counts, and concrete per-group segment assignments, `split` plus checksum-aware completion now constrain follow-up planning, and post-bootstrap core-planned follow-up ranges now run through a real concurrent segment executor rather than a serial CLI loop; inline payload checksum verification is real, storage now exposes observed byte sinks including a file-backed variant with explicit write-failure coverage, connector-returned inline bodies are normalized through the same sink before protocol/CLI/RPC consume it, and a live blocking HTTP connector can fetch local responses into sink-backed streamed results that runtime can complete against, but broader live-transport truth is still incomplete | implemented | `crates/aria2-rust-pro-storage/src/io.rs`, `crates/aria2-rust-pro-protocol/src/downloader.rs`, `crates/aria2-rust-pro-protocol/src/http.rs`, `crates/aria2-rust-pro-core/src/engine.rs`, `crates/aria2-rust-pro-core/src/request.rs`, `crates/aria2-rust-pro-core/src/scheduler.rs`, `crates/aria2-rust-pro-core/src/session.rs`, `crates/aria2-rust-pro-rpc/src/dispatcher.rs`, `crates/aria2-rust-pro-cli/src/lib.rs`, `crates/aria2-rust-pro-tests/src/lib.rs` | Extend beyond scripted fixture success paths into TLS-specific live coverage and deeper segment progress persistence through session/control/RPC | +| Binary `.aria2` compatibility | Upstream binary `.aria2` headers are auto-detected on read, decoded into current control metadata, and emitted again as the leading prefix when the metadata can be represented safely | verified | `crates/aria2-rust-pro-storage/src/control.rs` binary reader/writer plus fixture-backed tests for v0000/v0001 and round-trip recovery | + +Compatibility note: this ledger records that HTTP range/resume/retry has strong type/model coverage and partial runtime wiring, but does not yet claim full aria2-equivalent runtime behavior until retry/partial-range/restart-coherence is validated end-to-end. diff --git a/docs/compatibility/goldens/README.md b/docs/compatibility/goldens/README.md new file mode 100644 index 0000000..91fe87f --- /dev/null +++ b/docs/compatibility/goldens/README.md @@ -0,0 +1,28 @@ +# CLI Golden Snapshots + +This directory stores evidence-backed CLI compatibility snapshots captured from: + +- upstream Windows aria2 `1.37.0` +- the current local `aria2-rust-pro` binary + +The current capture script is: + +- `xtask compat capture-cli-goldens` +- `scripts/compat/capture-cli-goldens.ps1` (thin compatibility shim) + +It writes: + +- `cli/upstream/version.txt` +- `cli/upstream/help-all.txt` +- `cli/rust/version.txt` +- `cli/rust/help-all.txt` +- `cli/manifest.json` + +Run it from the repo root with: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- compat capture-cli-goldens +powershell -NoProfile -File .\scripts\compat\capture-cli-goldens.ps1 +``` + +These files are evidence. They do not claim parity by themselves. diff --git a/docs/compatibility/goldens/cli/manifest.json b/docs/compatibility/goldens/cli/manifest.json new file mode 100644 index 0000000..200e4f6 --- /dev/null +++ b/docs/compatibility/goldens/cli/manifest.json @@ -0,0 +1,9 @@ +{ + "generated_at_utc": "2026-05-27T02:54:18Z", + "captured": [ + "upstream/version.txt", + "upstream/help-all.txt", + "rust/version.txt", + "rust/help-all.txt" + ] +} diff --git a/docs/compatibility/goldens/cli/rust/help-all.txt b/docs/compatibility/goldens/cli/rust/help-all.txt new file mode 100644 index 0000000..e406d93 --- /dev/null +++ b/docs/compatibility/goldens/cli/rust/help-all.txt @@ -0,0 +1,220 @@ +Usage: aria2c [OPTIONS] [URI | MAGNET | TORRENT_FILE | METALINK_FILE]... +Printing all options. +Options: + -v, --version Print the version number and exit. + Tags: #basic + -h, --help[=TAG|KEYWORD] Print usage and exit. + + Possible Values: #basic, #advanced, #http, #https, #ftp, #metalink, #bittorrent, #cookie, #hook, #file, #rpc, #checksum, #experimental, #deprecated, #help, #all + Default: #basic + Tags: #basic, #help + --dir=PATH download target directory + + Possible Values: /path/to/file + Default: . + Tags: #advanced, #basic + --out=FILE output file name override + + Possible Values: FILE + Tags: #advanced, #basic + --split=NUM piece split count + + Possible Values: NUM + Default: 5 + Tags: #advanced, #basic, #ftp, #http + --continue[=true|false], -c resume partial download + + Possible Values: true, false + Default: false + Tags: #advanced, #basic + --enable-rpc[=true|false] enable rpc server + + Possible Values: true, false + Default: false + Tags: #basic, #rpc + --rpc-listen-port=PORT rpc tcp port + + Possible Values: PORT + Default: 6800 + Tags: #basic, #rpc + --rpc-listen-all[=true|false] bind rpc server to all interfaces + + Possible Values: true, false + Default: false + Tags: #basic, #rpc + --rpc-secret=VALUE rpc auth token + Tags: #basic, #rpc + --listen-port=PORT bt tcp/udp listen port + + Possible Values: PORT + Default: 6881 + Tags: #basic, #bittorrent + --dht-listen-port=PORT dht udp listen port + + Possible Values: PORT + Default: 6881 + Tags: #basic, #bittorrent + --bt-tracker=URI,... bt tracker announce list + + Possible Values: URI,... + Tags: #basic, #bittorrent + --on-download-complete=COMMAND completion hook command + + Possible Values: COMMAND + Tags: #advanced, #basic + --on-download-stop=COMMAND stop hook command + + Possible Values: COMMAND + Tags: #advanced, #basic + --disable-ipv6[=true|false] disable ipv6 sockets and resolution + + Possible Values: true, false + Default: true + Tags: #basic + --user-agent=VALUE, -U request user agent header + Tags: #basic, #http + --header=HEADER custom request headers + + Possible Values: HEADER + Tags: #basic, #http + --all-proxy=URI generic proxy endpoint + + Possible Values: URI + Tags: #basic, #ftp, #http + --http-proxy=URI http proxy endpoint + + Possible Values: URI + Tags: #basic, #ftp, #http + --https-proxy=URI https proxy endpoint + + Possible Values: URI + Tags: #basic, #ftp, #http + --no-proxy=HOST,... proxy bypass host list + + Possible Values: HOST,... + Tags: #basic, #ftp, #http + --check-certificate[=true|false] verify peer and host certificates + + Possible Values: true, false + Default: true + Tags: #basic, #https + --ca-certificate=PATH ca certificate file + + Possible Values: /path/to/file + Tags: #basic, #https + --certificate=PATH client certificate file + + Possible Values: /path/to/file + Tags: #basic, #https + --private-key=PATH client private key file + + Possible Values: /path/to/file + Tags: #basic, #https + --retry-wait=SEC retry delay in seconds + + Possible Values: SEC + Default: 0 + Tags: #basic, #http + --max-tries=NUM maximum retry attempts + + Possible Values: NUM + Default: 5 + Tags: #basic, #http + --bt-save-metadata[=true|false] save metadata file + + Possible Values: true, false + Default: false + Tags: #basic, #bittorrent + --follow-torrent=true|false|mem torrent follow behavior + + Possible Values: true|false|mem + Default: true + Tags: #basic, #bittorrent + --metalink-enable-unique-protocol[=true|false] + metalink dedupe by protocol + + Possible Values: true, false + Default: true + Tags: #basic, #metalink + --max-overall-download-limit=SIZE global aggregate download-speed cap + + Possible Values: SIZE + Default: 0 + Tags: #advanced, #basic, #bittorrent, #ftp, #http + --max-download-limit=SIZE per-download download-speed cap + + Possible Values: SIZE + Default: 0 + Tags: #advanced, #basic, #bittorrent, #ftp, #http + --max-overall-upload-limit=SIZE global aggregate upload-speed cap + + Possible Values: SIZE + Default: 0 + Tags: #advanced, #basic, #bittorrent, #ftp, #http + --max-upload-limit=SIZE per-download upload-speed cap + + Possible Values: SIZE + Default: 0 + Tags: #advanced, #basic, #bittorrent, #ftp, #http + --disk-cache=SIZE configured disk cache budget + + Possible Values: SIZE + Default: 16M + Tags: #advanced, #basic, #ftp, #http + --max-connection-per-server=NUM, -x + legacy speed tuning + + Possible Values: NUM + Default: 1 + Tags: #advanced, #basic, #ftp, #http + --min-split-size=SIZE pro lower split-size floor target + + Possible Values: SIZE + Default: 20M + Tags: #advanced, #basic, #ftp, #http + --piece-length=SIZE pro lower piece-length floor target + + Possible Values: SIZE + Default: 1M + Tags: #advanced, #basic, #ftp, #http + --retry-on-400[=true|false] retry HTTP 400 when explicitly enabled + + Possible Values: true, false + Default: false + Tags: #basic, #http, #rpc + --retry-on-403[=true|false] retry HTTP 403 when explicitly enabled + + Possible Values: true, false + Default: false + Tags: #basic, #http, #rpc + --retry-on-406[=true|false] retry HTTP 406 when explicitly enabled + + Possible Values: true, false + Default: false + Tags: #basic, #http, #rpc + --retry-on-unknown[=true|false] retry unknown HTTP failure when explicitly enabled + + Possible Values: true, false + Default: false + Tags: #basic, #http, #rpc + --input-file=PATH, -i load uri list from file + + Possible Values: PATH + Tags: #advanced, #basic + --save-session=PATH save active tasks on exit + + Possible Values: PATH + Tags: #advanced, #basic + --save-session-interval=SEC periodic session flush interval + + Possible Values: SEC + Default: 0 + Tags: #advanced, #basic + --no-want-digest-header[=true|false], --http-want-digest + compat switch for digest header behavior + + Possible Values: true, false + Default: false + Tags: #basic, #experimental, #http +Refer to man page for more information. + diff --git a/docs/compatibility/goldens/cli/rust/version.txt b/docs/compatibility/goldens/cli/rust/version.txt new file mode 100644 index 0000000..c75c95b --- /dev/null +++ b/docs/compatibility/goldens/cli/rust/version.txt @@ -0,0 +1,12 @@ +aria2 version 1.37.0 +Rust rewrite package: aria2-rust-pro 1.0.0 +Compatibility baseline: 1f1323128cae942f5440c035cb5f42788b3de33f + +** Configuration ** +Enabled Features: Async DNS, BitTorrent, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP +Hash Algorithms: sha-1, sha-224, sha-256, sha-384, sha-512, md5, adler32 +Libraries: tokio, reqwest, rustls, quick-xml +System: windows (x86_64) + +Report bugs to https://github.com/aria2/aria2/issues +Visit https://aria2.github.io/ diff --git a/docs/compatibility/goldens/cli/upstream/help-all.txt b/docs/compatibility/goldens/cli/upstream/help-all.txt new file mode 100644 index 0000000..dd245a0 --- /dev/null +++ b/docs/compatibility/goldens/cli/upstream/help-all.txt @@ -0,0 +1,1766 @@ +Usage: aria2c [OPTIONS] [URI | MAGNET | TORRENT_FILE | METALINK_FILE]... +Printing all options. +Options: + -v, --version Print the version number and exit. + + Tags: #basic + + -h, --help[=TAG|KEYWORD] Print usage and exit. + The help messages are classified with tags. A tag + starts with "#". For example, type "--help=#http" + to get the usage for the options tagged with + "#http". If non-tag word is given, print the usage + for the options whose name includes that word. + + Possible Values: #basic, #advanced, #http, #https, #ftp, #metalink, #bittorrent, #cookie, #hook, #file, #rpc, #checksum, #experimental, #deprecated, #help, #all + Default: #basic + Tags: #basic, #help + + -t, --timeout=SEC Set timeout in seconds. + + Possible Values: 1-600 + Default: 60 + Tags: #http, #ftp + + --connect-timeout=SEC Set the connect timeout in seconds to establish + connection to HTTP/FTP/proxy server. After the + connection is established, this option makes no + effect and --timeout option is used instead. + + Possible Values: 1-600 + Default: 60 + Tags: #http, #ftp + + -m, --max-tries=N Set number of tries. 0 means unlimited. + + Possible Values: 0-* + Default: 5 + Tags: #http, #ftp + + --auto-save-interval=SEC Save a control file(*.aria2) every SEC seconds. + If 0 is given, a control file is not saved during + download. aria2 saves a control file when it stops + regardless of the value. + + Possible Values: 0-600 + Default: 60 + Tags: #advanced + + -l, --log=LOG The file name of the log file. If '-' is + specified, log is written to stdout. + + Possible Values: /path/to/file, - + Tags: #basic + + -d, --dir=DIR The directory to store the downloaded file. + + Possible Values: /path/to/directory + Default: C:\workspace\aria2-rust-pro + Tags: #basic, #file + + -o, --out=FILE The file name of the downloaded file. It is + always relative to the directory given in -d + option. When the -Z option is used, this option + will be ignored. + + Possible Values: /path/to/file + Tags: #basic, #http, #ftp, #file + + -s, --split=N Download a file using N connections. If more + than N URIs are given, first N URIs are used and + remaining URLs are used for backup. If less than + N URIs are given, those URLs are used more than + once so that N connections total are made + simultaneously. The number of connections to the + same host is restricted by the + --max-connection-per-server option. See also the + --min-split-size option. + + Possible Values: 1-* + Default: 5 + Tags: #basic, #http, #ftp + + -D, --daemon[=true|false] Run as daemon. The current working directory will + be changed to "/" and standard input, standard + output and standard error will be redirected to + "/dev/null". + + Possible Values: true, false + Default: false + Tags: #advanced + + --referer=REFERER Set an http referrrer (Referer). This affects + all http/https downloads. If "*" is given, + the download URI is also used as the referrer. + This may be useful when used together with + the -P option. + + Tags: #http + + --lowest-speed-limit=SPEED Close connection if download speed is lower than + or equal to this value(bytes per sec). + 0 means aria2 does not have a lowest speed limit. + You can append K or M(1K = 1024, 1M = 1024K). + This option does not affect BitTorrent downloads. + + Possible Values: 0-* + Default: 0 + Tags: #http, #ftp + + --piece-length=LENGTH Set a piece length for HTTP/FTP downloads. This + is the boundary when aria2 splits a file. All + splits occur at multiple of this length. This + option will be ignored in BitTorrent downloads. + It will be also ignored if Metalink file + contains piece hashes. + + Possible Values: 1048576-1073741824 + Default: 1M + Tags: #advanced, #http, #ftp + + --max-overall-download-limit=SPEED Set max overall download speed in bytes/sec. + 0 means unrestricted. + You can append K or M(1K = 1024, 1M = 1024K). + To limit the download speed per download, use + --max-download-limit option. + + Possible Values: 0-* + Default: 0 + Tags: #http, #ftp, #bittorrent + + --max-download-limit=SPEED Set max download speed per each download in + bytes/sec. 0 means unrestricted. + You can append K or M(1K = 1024, 1M = 1024K). + To limit the overall download speed, use + --max-overall-download-limit option. + + Possible Values: 0-* + Default: 0 + Tags: #http, #ftp, #bittorrent + + --file-allocation=METHOD Specify file allocation method. + 'none' doesn't pre-allocate file space. 'prealloc' + pre-allocates file space before download begins. + This may take some time depending on the size of + the file. + If you are using newer file systems such as ext4 + (with extents support), btrfs, xfs or NTFS + (MinGW build only), 'falloc' is your best + choice. It allocates large(few GiB) files + almost instantly. Don't use 'falloc' with legacy + file systems such as ext3 and FAT32 because it + takes almost the same time as 'prealloc' and it + blocks aria2 entirely until allocation finishes. + 'falloc' may not be available if your system + doesn't have posix_fallocate() function. + 'trunc' uses ftruncate() system call or + platform-specific counterpart to truncate a file + to a specified length. + + Possible Values: none, prealloc, trunc, falloc + Default: prealloc + Tags: #basic, #file + + --no-file-allocation-limit=SIZE No file allocation is made for files whose + size is smaller than SIZE. + You can append K or M(1K = 1024, 1M = 1024K). + + Possible Values: 0-* + Default: 5M + Tags: #advanced, #file + + --allow-overwrite[=true|false] Restart download from scratch if the + corresponding control file doesn't exist. See + also --auto-file-renaming option. + + Possible Values: true, false + Default: false + Tags: #advanced, #file + + --realtime-chunk-checksum[=true|false] Validate chunk of data by calculating + checksum while downloading a file if chunk + checksums are provided. + + Possible Values: true, false + Default: true + Tags: #metalink, #checksum + + -V, --check-integrity[=true|false] Check file integrity by validating piece + hashes or a hash of entire file. This option has + effect only in BitTorrent, Metalink downloads + with checksums or HTTP(S)/FTP downloads with + --checksum option. If piece hashes are provided, + this option can detect damaged portions of a file + and re-download them. If a hash of entire file is + provided, hash check is only done when file has + been already download. This is determined by file + length. If hash check fails, file is + re-downloaded from scratch. If both piece hashes + and a hash of entire file are provided, only + piece hashes are used. + + Possible Values: true, false + Default: false + Tags: #basic, #metalink, #bittorrent, #file, #checksum + + --netrc-path=FILE Specify the path to the netrc file. + + Possible Values: /path/to/file + Default: C:/Users//.netrc + Tags: + + -c, --continue[=true|false] Continue downloading a partially downloaded + file. Use this option to resume a download + started by a web browser or another program + which downloads files sequentially from the + beginning. Currently this option is only + applicable to http(s)/ftp downloads. + + Possible Values: true, false + Default: false + Tags: #basic, #http, #ftp + + -n, --no-netrc[=true|false] Disables netrc support. + + Possible Values: true, false + Default: false + Tags: #http, #ftp + + -i, --input-file=FILE Downloads URIs found in FILE. You can specify + multiple URIs for a single entity: separate + URIs on a single line using the TAB character. + Reads input from stdin when '-' is specified. + Additionally, options can be specified after each + line of URI. This optional line must start with + one or more white spaces and have one option per + single line. See INPUT FILE section of man page + for details. See also --deferred-input option. + + Possible Values: /path/to/file, - + Tags: #basic + + --deferred-input[=true|false] If true is given, aria2 does not read all URIs + and options from file specified by -i option at + startup, but it reads one by one when it needs + later. This may reduce memory usage if input + file contains a lot of URIs to download. + If false is given, aria2 reads all URIs and + options at startup. + + Possible Values: true, false + Default: false + Tags: #advanced + + -j, --max-concurrent-downloads=N Set maximum number of parallel downloads for + every static (HTTP/FTP) URL, torrent and metalink. + See also --split and --optimize-concurrent-downloads options. + + Possible Values: 1-* + Default: 5 + Tags: #basic + + --optimize-concurrent-downloads[=true|false|A:B] Optimizes the number of + concurrent downloads according to the bandwidth + available. aria2 uses the download speed observed + in the previous downloads to adapt the number of + downloads launched in parallel according to the + rule N = A + B Log10(speed in Mbps). The + coefficients A and B can be customized in the + option arguments with A and B separated by a + colon. The default values (A=5,B=25) lead to + using typically 5 parallel downloads on 1Mbps + networks and above 50 on 100Mbps networks. The + number of parallel downloads remains constrained + under the maximum defined by the + max-concurrent-downloads parameter. + + Possible Values: true, false, A:B + Default: false + Tags: #advanced + + -Z, --force-sequential[=true|false] Fetch URIs in the command-line sequentially + and download each URI in a separate session, like + the usual command-line download utilities. + + Possible Values: true, false + Default: false + Tags: #basic + + --auto-file-renaming[=true|false] Rename file name if the same file already + exists. This option works only in http(s)/ftp + download. + The new file name has a dot and a number(1..9999) + appended after the name, but before the file + extension, if any. + + Possible Values: true, false + Default: true + Tags: #advanced, #file + + -P, --parameterized-uri[=true|false] Enable parameterized URI support. + You can specify set of parts: + http://{sv1,sv2,sv3}/foo.iso + Also you can specify numeric sequences with step + counter: + http://host/image[000-100:2].img + A step counter can be omitted. + If all URIs do not point to the same file, such + as the second example above, -Z option is + required. + + Possible Values: true, false + Default: false + Tags: #advanced + + --allow-piece-length-change[=true|false] If false is given, aria2 aborts + download when a piece length is different from + one in a control file. If true is given, you can + proceed but some download progress will be lost. + + Possible Values: true, false + Default: false + Tags: #advanced + + --no-conf[=true|false] Disable loading aria2.conf file. + + Possible Values: true, false + Default: false + Tags: #advanced + + --conf-path=PATH Change the configuration file path to PATH. + + Possible Values: /path/to/file + Default: C:/Users//.config/aria2/aria2.conf + Tags: #advanced + + --stop=SEC Stop application after SEC seconds has passed. + If 0 is given, this feature is disabled. + + Possible Values: 0-2147483647 + Default: 0 + Tags: #advanced + + -q, --quiet[=true|false] Make aria2 quiet(no console output). + + Possible Values: true, false + Default: false + Tags: #advanced + + --async-dns[=true|false] Enable asynchronous DNS. + + Possible Values: true, false + Default: true + Tags: #advanced + + --summary-interval=SEC Set interval to output download progress summary. + Setting 0 suppresses the output. + + Possible Values: 0-2147483647 + Default: 60 + Tags: #advanced + + --log-level=LEVEL Set log level to output to file specified using + --log option. + + Possible Values: debug, info, notice, warn, error + Default: debug + Tags: #advanced + + --console-log-level=LEVEL Set log level to output to console. + + Possible Values: debug, info, notice, warn, error + Default: notice + Tags: #advanced + + --uri-selector=SELECTOR Specify URI selection algorithm. + If 'inorder' is given, URI is tried in the order + appeared in the URI list. + If 'feedback' is given, aria2 uses download speed + observed in the previous downloads and choose + fastest server in the URI list. This also + effectively skips dead mirrors. The observed + download speed is a part of performance profile + of servers mentioned in --server-stat-of and + --server-stat-if options. + If 'adaptive' is given, selects one of the best + mirrors for the first and reserved connections. + For supplementary ones, it returns mirrors which + has not been tested yet, and if each of them has + already been tested, returns mirrors which has to + be tested again. Otherwise, it doesn't select + anymore mirrors. Like 'feedback', it uses a + performance profile of servers. + + Possible Values: inorder, feedback, adaptive + Default: feedback + Tags: #http, #ftp + + --server-stat-timeout=SEC Specifies timeout in seconds to invalidate + performance profile of the servers since the last + contact to them. + + Possible Values: 0-2147483647 + Default: 86400 + Tags: #http, #ftp + + --server-stat-if=FILE Specify the filename to load performance profile + of the servers. The loaded data will be used in + some URI selector such as 'feedback'. + See also --uri-selector option + + Possible Values: /path/to/file + Tags: #http, #ftp + + --server-stat-of=FILE Specify the filename to which performance profile + of the servers is saved. You can load saved data + using --server-stat-if option. + + Possible Values: /path/to/file + Tags: #http, #ftp + + -R, --remote-time[=true|false] Retrieve timestamp of the remote file from the + remote HTTP/FTP server and if it is available, + apply it to the local file. + + Possible Values: true, false + Default: false + Tags: #http, #ftp + + --max-file-not-found=NUM If aria2 receives `file not found' status from the + remote HTTP/FTP servers NUM times without getting + a single byte, then force the download to fail. + Specify 0 to disable this option. + This options is effective only when using + HTTP/FTP servers. The number of retry attempt is + counted toward --max-tries, so it should be + configured too. + + Possible Values: 0-* + Default: 0 + Tags: #http, #ftp + + --event-poll=POLL Specify the method for polling events. + + Possible Values: select + Default: select + Tags: #advanced + + --enable-rpc[=true|false] Enable JSON-RPC/XML-RPC server. + It is strongly recommended to set secret + authorization token using --rpc-secret option. + See also --rpc-listen-port option. + + Possible Values: true, false + Default: false + Tags: #rpc + + --rpc-listen-port=PORT Specify a port number for JSON-RPC/XML-RPC server + to listen to. + + Possible Values: 1024-65535 + Default: 6800 + Tags: #rpc + + --rpc-user=USER Set JSON-RPC/XML-RPC user. This option will be + deprecated in the future release. Migrate to + --rpc-secret option as soon as possible. + + Tags: #rpc, #deprecated + + --rpc-passwd=PASSWD Set JSON-RPC/XML-RPC password. This option will + be deprecated in the future release. Migrate to + --rpc-secret option as soon as possible. + + Tags: #rpc, #deprecated + + --rpc-max-request-size=SIZE Set max size of JSON-RPC/XML-RPC request. If aria2 + detects the request is more than SIZE bytes, it + drops connection. + + Possible Values: 0-* + Default: 2M + Tags: #rpc + + --rpc-listen-all[=true|false] Listen incoming JSON-RPC/XML-RPC requests on all + network interfaces. If false is given, listen only + on local loopback interface. + + Possible Values: true, false + Default: false + Tags: #rpc + + --rpc-allow-origin-all[=true|false] Add Access-Control-Allow-Origin header + field with value '*' to the RPC response. + + Possible Values: true, false + Default: false + Tags: #rpc + + --rpc-certificate=FILE Use the certificate in FILE for RPC server. + The certificate must be in PEM format. + Use --rpc-private-key option to specify the + private key. Use --rpc-secure option to enable + encryption. + + Possible Values: /path/to/file + Tags: #rpc + + --rpc-private-key=FILE Use the private key in FILE for RPC server. + The private key must be decrypted and in PEM + format. Use --rpc-secure option to enable + encryption. See also --rpc-certificate option. + + Possible Values: /path/to/file + Tags: #rpc + + --rpc-secure[=true|false] RPC transport will be encrypted by SSL/TLS. + The RPC clients must use https scheme to access + the server. For WebSocket client, use wss + scheme. Use --rpc-certificate and + --rpc-private-key options to specify the + server certificate and private key. + + Possible Values: true, false + Default: false + Tags: #rpc + + --rpc-save-upload-metadata[=true|false] Save the uploaded torrent or + metalink metadata in the directory specified + by --dir option. The filename consists of + SHA-1 hash hex string of metadata plus + extension. For torrent, the extension is + '.torrent'. For metalink, it is '.meta4'. + If false is given to this option, the + downloads added by aria2.addTorrent or + aria2.addMetalink will not be saved by + --save-session option. + + Possible Values: true, false + Default: true + Tags: #rpc + + --dry-run[=true|false] If true is given, aria2 just checks whether the + remote file is available and doesn't download + data. This option has effect on HTTP/FTP download. + BitTorrent downloads are canceled if true is + specified. + + Possible Values: true, false + Default: false + Tags: #http, #ftp + + --reuse-uri[=true|false] Reuse already used URIs if no unused URIs are + left. + + Possible Values: true, false + Default: true + Tags: #http, #ftp + + --on-download-start=COMMAND Set the command to be executed after download + got started. aria2 passes 3 arguments to COMMAND: + GID, the number of files and file path. See Event + Hook in man page for more details. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --on-download-pause=COMMAND Set the command to be executed after download + was paused. + See --on-download-start option for the + requirement of COMMAND. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --on-download-stop=COMMAND Set the command to be executed after download + stopped. You can override the command to be + executed for particular download result using + --on-download-complete and --on-download-error. If + they are specified, command specified in this + option is not executed. + See --on-download-start option for the + requirement of COMMAND. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --on-download-complete=COMMAND Set the command to be executed after download + completed. + See --on-download-start option for the + requirement of COMMAND. + See also --on-download-stop option. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --on-download-error=COMMAND Set the command to be executed after download + aborted due to error. + See --on-download-start option for the + requirement of COMMAND. + See also --on-download-stop option. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --interface=INTERFACE Bind sockets to given interface. You can specify + interface name, IP address and hostname. + + Possible Values: interface, IP address, hostname + Tags: #advanced + + --multiple-interface=INTERFACES Comma separated list of interfaces to bind + sockets to. Requests will be split among the + interfaces to achieve link aggregation. You can + specify interface name, IP address and hostname. + If --interface is used, this option will be + ignored. + + Possible Values: interface, IP address, hostname + Tags: #advanced + + --disable-ipv6[=true|false] Disable IPv6. + + Possible Values: true, false + Default: false + Tags: #advanced + + --human-readable[=true|false] Print sizes and speed in human readable format + (e.g., 1.2Ki, 3.4Mi) in the console readout. + + Possible Values: true, false + Default: true + Tags: #advanced + + --remove-control-file[=true|false] Remove control file before download. Using + with --allow-overwrite=true, download always + starts from scratch. This will be useful for + users behind proxy server which disables resume. + + Possible Values: true, false + Default: false + Tags: #advanced + + --always-resume[=true|false] Always resume download. If true is given, aria2 + always tries to resume download and if resume is + not possible, aborts download. If false is given, + when all given URIs do not support resume or + aria2 encounters N URIs which does not support + resume (N is the value specified using + --max-resume-failure-tries option), aria2 + downloads file from scratch. + See --max-resume-failure-tries option. + + Possible Values: true, false + Default: true + Tags: #advanced, #http, #ftp + + --max-resume-failure-tries=N When used with --always-resume=false, aria2 + downloads file from scratch when aria2 detects N + number of URIs that does not support resume. If N + is 0, aria2 downloads file from scratch when all + given URIs do not support resume. + See --always-resume option. + + Possible Values: 0-* + Default: 0 + Tags: #advanced, #http, #ftp + + --save-session=FILE Save error/unfinished downloads to FILE on exit. + You can pass this output file to aria2c with -i + option on restart. Please note that downloads + added by aria2.addTorrent and aria2.addMetalink + RPC method and whose metadata could not be saved + as a file will not be saved. Downloads removed + using aria2.remove and aria2.forceRemove will not + be saved. + + Possible Values: /path/to/file + Tags: #advanced + + -x, --max-connection-per-server=NUM The maximum number of connections to one + server for each download. + + Possible Values: 1-16 + Default: 1 + Tags: #basic, #http, #ftp + + -k, --min-split-size=SIZE aria2 does not split less than 2*SIZE byte range. + For example, let's consider downloading 20MiB + file. If SIZE is 10M, aria2 can split file into 2 + range [0-10MiB) and [10MiB-20MiB) and download it + using 2 sources(if --split >= 2, of course). + If SIZE is 15M, since 2*15M > 20MiB, aria2 does + not split file and download it using 1 source. + You can append K or M(1K = 1024, 1M = 1024K). + + Possible Values: 1048576-1073741824 + Default: 20M + Tags: #basic, #http, #ftp + + --conditional-get[=true|false] Download file only when the local file is older + than remote file. Currently, this function has + many limitations. See man page for details. + + Possible Values: true, false + Default: false + Tags: #advanced, #http + + --enable-async-dns6[=true|false] Enable IPv6 name resolution in asynchronous + DNS resolver. This option will be ignored when + --async-dns=false. + + Possible Values: true, false + Tags: #advanced, #deprecated + + --max-download-result=NUM Set maximum number of download result kept in + memory. The download results are completed/error/ + removed downloads. The download results are stored + in FIFO queue and it can store at most NUM + download results. When queue is full and new + download result is created, oldest download result + is removed from the front of the queue and new one + is pushed to the back. Setting big number in this + option may result high memory consumption after + thousands of downloads. Specifying 0 means no + download result is kept. Note that unfinished + downloads are kept in memory regardless of this + option value. See + --keep-unfinished-download-result option. + + Possible Values: 0-* + Default: 1000 + Tags: #advanced + + --retry-wait=SEC Set the seconds to wait between retries. + With SEC > 0, aria2 will retry download when the + HTTP server returns 503 response. + + Possible Values: 0-600 + Default: 0 + Tags: #http, #ftp + + --async-dns-server=IPADDRESS[,...] Comma separated list of DNS server address + used in asynchronous DNS resolver. Usually + asynchronous DNS resolver reads DNS server + addresses from /etc/resolv.conf. When this option + is used, it uses DNS servers specified in this + option instead of ones in /etc/resolv.conf. You + can specify both IPv4 and IPv6 address. This + option is useful when the system does not have + /etc/resolv.conf and user does not have the + permission to create it. + + Tags: #advanced + + --show-console-readout[=true|false] Show console readout. + + Possible Values: true, false + Default: true + Tags: #advanced + + --stream-piece-selector=SELECTOR Specify piece selection algorithm + used in HTTP/FTP download. Piece means fixed + length segment which is downloaded in parallel + in segmented download. If 'default' is given, + aria2 selects piece so that it reduces the + number of establishing connection. This is + reasonable default behaviour because + establishing connection is an expensive + operation. + If 'inorder' is given, aria2 selects piece which + has minimum index. Index=0 means first of the + file. This will be useful to view movie while + downloading it. --enable-http-pipelining option + may be useful to reduce reconnection overhead. + Please note that aria2 honors + --min-split-size option, so it will be necessary + to specify a reasonable value to + --min-split-size option. + If 'random' is given, aria2 selects piece + randomly. Like 'inorder', --min-split-size + option is honored. + If 'geom' is given, at the beginning aria2 + selects piece which has minimum index like + 'inorder', but it exponentially increasingly + keeps space from previously selected piece. This + will reduce the number of establishing connection + and at the same time it will download the + beginning part of the file first. This will be + useful to view movie while downloading it. + + Possible Values: default, inorder, random, geom + Default: default + Tags: #http, #ftp + + --truncate-console-readout[=true|false] Truncate console readout to fit in + a single line. + + Possible Values: true, false + Default: true + Tags: #advanced + + --pause[=true|false] Pause download after added. This option is + effective only when --enable-rpc=true is given. + + Possible Values: true, false + Default: false + Tags: #advanced, #rpc + + --download-result=OPT This option changes the way "Download Results" + is formatted. If OPT is 'default', print GID, + status, average download speed and path/URI. If + multiple files are involved, path/URI of first + requested file is printed and remaining ones are + omitted. + If OPT is 'full', print GID, status, average + download speed, percentage of progress and + path/URI. The percentage of progress and + path/URI are printed for each requested file in + each row. + If OPT is 'hide', "Download Results" is hidden. + + Possible Values: default, full, hide + Default: default + Tags: #advanced + + --hash-check-only[=true|false] If true is given, after hash check using + --check-integrity option, abort download whether + or not download is complete. + + Possible Values: true, false + Default: false + Tags: #advanced, #metalink, #bittorrent, #file, #checksum + + --checksum=TYPE=DIGEST Set checksum. TYPE is hash type. The supported + hash type is listed in "Hash Algorithms" in + "aria2c -v". DIGEST is hex digest. + For example, setting sha-1 digest looks like + this: + sha-1=0192ba11326fe2298c8cb4de616f4d4140213838 + This option applies only to HTTP(S)/FTP + downloads. + + Possible Values: HASH_TYPE=HEX_DIGEST + Tags: #http, #ftp, #checksum + + --stop-with-process=PID Stop application when process PID is not running. + This is useful if aria2 process is forked from a + parent process. The parent process can fork aria2 + with its own pid and when parent process exits + for some reason, aria2 can detect it and shutdown + itself. + + Possible Values: 0-* + Tags: #advanced + + --enable-mmap[=true|false] Map files into memory. + + Possible Values: true, false + Default: false + Tags: #advanced, #experimental + + --force-save[=true|false] Save download with --save-session option even + if the download is completed or removed. This + option also saves control file in that + situations. This may be useful to save + BitTorrent seeding which is recognized as + completed state. + + Possible Values: true, false + Default: false + Tags: #advanced + + --save-not-found[=true|false] Save download with --save-session option even + if the file was not found on the server. This + option also saves control file in that + situations. + + Possible Values: true, false + Default: true + Tags: #advanced + + --disk-cache=SIZE Enable disk cache. If SIZE is 0, the disk cache + is disabled. This feature caches the downloaded + data in memory, which grows to at most SIZE + bytes. The cache storage is created for aria2 + instance and shared by all downloads. The one + advantage of the disk cache is reduce the disk + I/O because the data are written in larger unit + and it is reordered by the offset of the file. + If hash checking is involved and the data are + cached in memory, we don't need to read them + from the disk. + SIZE can include K or M(1K = 1024, 1M = 1024K). + + Possible Values: 0-* + Default: 16M + Tags: #advanced + + --gid=GID Set GID manually. aria2 identifies each + download by the ID called GID. The GID must be + hex string of 16 characters, thus [0-9a-fA-F] + are allowed and leading zeros must not be + stripped. The GID all 0 is reserved and must + not be used. The GID must be unique, otherwise + error is reported and the download is not added. + This option is useful when restoring the + sessions saved using --save-session option. If + this option is not used, new GID is generated + by aria2. + + Tags: #advanced + + --save-session-interval=SEC Save error/unfinished downloads to a file + specified by --save-session option every SEC + seconds. If 0 is given, file will be saved only + when aria2 exits. + + Possible Values: 0-* + Default: 0 + Tags: #advanced + + --enable-color[=true|false] Enable color output for a terminal. + + Possible Values: true, false + Default: true + Tags: #advanced + + --rpc-secret=TOKEN Set RPC secret authorization token. + + Tags: #rpc + + --dscp=DSCP Set DSCP value in outgoing IP packets of + BitTorrent traffic for QoS. This parameter sets + only DSCP bits in TOS field of IP packets, + not the whole field. If you take values + from /usr/include/netinet/ip.h divide them by 4 + (otherwise values would be incorrect, e.g. your + CS1 class would turn into CS4). If you take + commonly used values from RFC, network vendors' + documentation, Wikipedia or any other source, + use them as they are. + + Possible Values: 0-* + Default: 0 + Tags: #advanced + + --pause-metadata[=true|false] + Pause downloads created as a result of metadata + download. There are 3 types of metadata + downloads in aria2: (1) downloading .torrent + file. (2) downloading torrent metadata using + magnet link. (3) downloading metalink file. + These metadata downloads will generate downloads + using their metadata. This option pauses these + subsequent downloads. This option is effective + only when --enable-rpc=true is given. + + Possible Values: true, false + Default: false + Tags: #advanced, #rpc + + --min-tls-version=VERSION Specify minimum SSL/TLS version to enable. + + Possible Values: TLSv1.1, TLSv1.2, TLSv1.3 + Default: TLSv1.2 + Tags: #advanced + + --socket-recv-buffer-size=SIZE + Set the maximum socket receive buffer in bytes. + Specifying 0 will disable this option. This value + will be set to socket file descriptor using + SO_RCVBUF socket option with setsockopt() call. + + Possible Values: 0-16777216 + Default: 0 + Tags: #advanced + + --max-mmap-limit=SIZE Set the maximum file size to enable mmap (see + --enable-mmap option). The file size is + determined by the sum of all files contained in + one download. For example, if a download + contains 5 files, then file size is the total + size of those files. If file size is strictly + greater than the size specified in this option, + mmap will be disabled. + + Possible Values: 0-* + Default: 9223372036854775807 + Tags: #advanced + + --stderr[=true|false] Redirect all console output that would be + otherwise printed in stdout to stderr. + + Possible Values: true, false + Default: false + Tags: #advanced + + --keep-unfinished-download-result[=true|false] + Keep unfinished download results even if doing + so exceeds --max-download-result. This is useful + if all unfinished downloads must be saved in + session file (see --save-session option). Please + keep in mind that there is no upper bound to the + number of unfinished download result to keep. If + that is undesirable, turn this option off. + + Possible Values: true, false + Default: true + Tags: #advanced + + --ftp-user=USER Set FTP user. This affects all URLs. + + Tags: #basic, #ftp + + --ftp-passwd=PASSWD Set FTP password. This affects all URLs. + + Tags: #basic, #ftp + + --ftp-type=TYPE Set FTP transfer type. + + Possible Values: binary, ascii + Default: binary + Tags: #ftp + + -p, --ftp-pasv[=true|false] Use the passive mode in FTP. If false is given, + the active mode will be used. + + Possible Values: true, false + Default: true + Tags: #ftp + + --ftp-reuse-connection[=true|false] Reuse connection in FTP. + + Possible Values: true, false + Default: true + Tags: #ftp + + --ssh-host-key-md=TYPE=DIGEST + Set checksum for SSH host public key. TYPE is + hash type. The supported hash type is sha-1 or + md5. DIGEST is hex digest. For example: + sha-1=b030503d4de4539dc7885e6f0f5e256704edf4c3 + This option can be used to validate server's + public key when SFTP is used. If this option is + not set, which is default, no validation takes + place. + + Possible Values: HASH_TYPE=HEX_DIGEST + Tags: #ftp + + --http-user=USER Set HTTP user. This affects all URLs. + + Tags: #basic, #http + + --http-passwd=PASSWD Set HTTP password. This affects all URLs. + + Tags: #basic, #http + + -U, --user-agent=USER_AGENT Set user agent for http(s) downloads. + + Default: aria2/1.37.0 + Tags: #http + + --load-cookies=FILE Load Cookies from FILE using the Firefox3 format + and Mozilla/Firefox(1.x/2.x)/Netscape format. + + Possible Values: /path/to/file + Tags: #basic, #http, #cookie + + --save-cookies=FILE Save Cookies to FILE in Mozilla/Firefox(1.x/2.x)/ + Netscape format. If FILE already exists, it is + overwritten. Session Cookies are also saved and + their expiry values are treated as 0. + + Possible Values: /path/to/file + Tags: #http, #cookie + + --enable-http-keep-alive[=true|false] Enable HTTP/1.1 persistent connection. + + Possible Values: true, false + Default: true + Tags: #http + + --enable-http-pipelining[=true|false] Enable HTTP/1.1 pipelining. + + Possible Values: true, false + Default: false + Tags: #http + + --header=HEADER Append HEADER to HTTP request header. You can use + this option repeatedly to specify more than one + header: + aria2c --header="X-A: b78" --header="X-B: 9J1" + http://host/file + + Tags: #http + + --certificate=FILE Use the client certificate in FILE. + The certificate must be in PEM format. + You may use --private-key option to specify the + private key. + + Possible Values: /path/to/file + Tags: #http, #https + + --private-key=FILE Use the private key in FILE. + The private key must be decrypted and in PEM + format. See also --certificate option. + + Possible Values: /path/to/file + Tags: #http, #https + + --ca-certificate=FILE Use the certificate authorities in FILE to verify + the peers. The certificate file must be in PEM + format and can contain multiple CA certificates. + Use --check-certificate option to enable + verification. + + Possible Values: /path/to/file + Tags: #http, #https + + --check-certificate[=true|false] Verify the peer using certificates specified + in --ca-certificate option. + + Possible Values: true, false + Default: true + Tags: #http, #https + + --use-head[=true|false] Use HEAD method for the first request to the HTTP + server. + + Possible Values: true, false + Default: false + Tags: #http + + --http-auth-challenge[=true|false] Send HTTP authorization header only when it + is requested by the server. If false is set, then + authorization header is always sent to the server. + There is an exception: if username and password + are embedded in URI, authorization header is + always sent to the server regardless of this + option. + + Possible Values: true, false + Default: false + Tags: #http + + --http-no-cache[=true|false] Send Cache-Control: no-cache and Pragma: no-cache + header to avoid cached content. If false is + given, these headers are not sent and you can add + Cache-Control header with a directive you like + using --header option. + + Possible Values: true, false + Default: false + Tags: #http + + --http-accept-gzip[=true|false] Send 'Accept-Encoding: deflate, gzip' request + header and inflate response if remote server + responds with 'Content-Encoding: gzip' or + 'Content-Encoding: deflate'. + + Possible Values: true, false + Default: false + Tags: #http + + --content-disposition-default-utf8[=true|false] Handle quoted string in + Content-Disposition header as UTF-8 instead of + ISO-8859-1, for example, the filename parameter, + but not the extended version filename*. + + Possible Values: true, false + Default: false + Tags: #advanced, #http + + --no-want-digest-header[=true|false] Whether to disable Want-Digest header + when doing requests. + + Possible Values: true, false + Default: false + Tags: #http + + --http-proxy=PROXY Use a proxy server for HTTP. To override a + previously defined proxy, use "". + See also the --all-proxy option. + This affects all http downloads. + + Possible Values: [http://][USER:PASSWORD@]HOST[:PORT] + Tags: #http + + --https-proxy=PROXY Use a proxy server for HTTPS. To override a + previously defined proxy, use "". + See also the --all-proxy option. + This affects all https downloads. + + Possible Values: [http://][USER:PASSWORD@]HOST[:PORT] + Tags: #http, #https + + --ftp-proxy=PROXY Use a proxy server for FTP. To override a + previously defined proxy, use "". + See also the --all-proxy option. + This affects all ftp downloads. + + Possible Values: [http://][USER:PASSWORD@]HOST[:PORT] + Tags: #ftp + + --all-proxy=PROXY Use a proxy server for all protocols. To override + a previously defined proxy, use "". + You also can override this setting and specify a + proxy server for a particular protocol using the + --http-proxy, --https-proxy and --ftp-proxy + options. + This affects all downloads. + + Possible Values: [http://][USER:PASSWORD@]HOST[:PORT] + Tags: #http, #https, #ftp + + --no-proxy=DOMAINS Specify comma separated hostnames, domains or + network address with or without CIDR block where + proxy should not be used. + + Possible Values: HOSTNAME,DOMAIN,NETWORK/CIDR + Tags: #http, #https, #ftp + + --proxy-method=METHOD Set the method to use in proxy request. + + Possible Values: get, tunnel + Default: get + Tags: #http, #ftp + + --http-proxy-user=USER Set user for --http-proxy. + + Tags: #http + + --http-proxy-passwd=PASSWD Set password for --http-proxy. + + Tags: #http + + --https-proxy-user=USER Set user for --https-proxy. + + Tags: #http, #https + + --https-proxy-passwd=PASSWD Set password for --https-proxy. + + Tags: #http, #https + + --ftp-proxy-user=USER Set user for --ftp-proxy. + + Tags: #ftp + + --ftp-proxy-passwd=PASSWD Set password for --ftp-proxy. + + Tags: #ftp + + --all-proxy-user=USER Set user for --all-proxy. + + Tags: #http, #https, #ftp + + --all-proxy-passwd=PASSWD Set password for --all-proxy. + + Tags: #http, #https, #ftp + + -S, --show-files[=true|false] Print file listing of .torrent, .meta4 and + .metalink file and exit. More detailed + information will be listed in case of torrent + file. + + Possible Values: true, false + Default: false + Tags: #basic, #metalink, #bittorrent + + --max-overall-upload-limit=SPEED Set max overall upload speed in bytes/sec. + 0 means unrestricted. + You can append K or M(1K = 1024, 1M = 1024K). + To limit the upload speed per torrent, use + --max-upload-limit option. + + Possible Values: 0-* + Default: 0 + Tags: #basic, #bittorrent + + -u, --max-upload-limit=SPEED Set max upload speed per each torrent in + bytes/sec. 0 means unrestricted. + You can append K or M(1K = 1024, 1M = 1024K). + To limit the overall upload speed, use + --max-overall-upload-limit option. + + Possible Values: 0-* + Default: 0 + Tags: #basic, #bittorrent + + -T, --torrent-file=TORRENT_FILE The path to the .torrent file. + + Possible Values: /path/to/file + Tags: #basic, #bittorrent + + --listen-port=PORT... Set TCP port number for BitTorrent downloads. + Multiple ports can be specified by using ',', + for example: "6881,6885". You can also use '-' + to specify a range: "6881-6999". ',' and '-' can + be used together. + + Possible Values: 1024-65535 + Default: 6881-6999 + Tags: #basic, #bittorrent + + --follow-torrent=true|false|mem If true or mem is specified, when a file + whose suffix is .torrent or content type is + application/x-bittorrent is downloaded, aria2 + parses it as a torrent file and downloads files + mentioned in it. + If mem is specified, a torrent file is not + written to the disk, but is just kept in memory. + If false is specified, the .torrent file is + downloaded to the disk, but is not parsed as a + torrent and its contents are not downloaded. + + Possible Values: true, mem, false + Default: true + Tags: #bittorrent + + --select-file=INDEX... Set file to download by specifying its index. + You can find the file index using the + --show-files option. Multiple indexes can be + specified by using ',', for example: "3,6". + You can also use '-' to specify a range: "1-5". + ',' and '-' can be used together. + When used with the -M option, index may vary + depending on the query(see --metalink-* options). + + Possible Values: 1-1048576 + Tags: #metalink, #bittorrent + + --seed-time=MINUTES Specify seeding time in (fractional) minutes. + Also see the --seed-ratio option. + + Possible Values: 0.0-* + Tags: #bittorrent + + --seed-ratio=RATIO Specify share ratio. Seed completed torrents + until share ratio reaches RATIO. + You are strongly encouraged to specify equals or + more than 1.0 here. Specify 0.0 if you intend to + do seeding regardless of share ratio. + If --seed-time option is specified along with + this option, seeding ends when at least one of + the conditions is satisfied. + + Possible Values: 0.0-* + Default: 1.0 + Tags: #bittorrent + + --peer-id-prefix=PEER_ID_PREFIX Specify the prefix of peer ID. The peer ID in + BitTorrent is 20 byte length. If more than 20 + bytes are specified, only first 20 bytes are + used. If less than 20 bytes are specified, random + byte data are added to make its length 20 bytes. + + Default: A2-1-37-0- + Tags: #bittorrent + + --peer-agent=PEER_AGENT Set client reported during Extended torrent handshakes + + Default: aria2/1.37.0 + Tags: #bittorrent + + --enable-peer-exchange[=true|false] Enable Peer Exchange extension. + + Possible Values: true, false + Default: true + Tags: #bittorrent + + --enable-dht[=true|false] Enable IPv4 DHT functionality. It also enables + UDP tracker support. If a private flag is set + in a torrent, aria2 doesn't use DHT for that + download even if ``true`` is given. + + Possible Values: true, false + Default: true + Tags: #basic, #bittorrent + + --dht-listen-port=PORT... Set UDP listening port used by DHT(IPv4, IPv6) + and UDP tracker. Multiple ports can be specified + by using ',', for example: "6881,6885". You can + also use '-' to specify a range: "6881-6999". + ',' and '-' can be used together. + + Possible Values: 1024-65535 + Default: 6881-6999 + Tags: #basic, #bittorrent + + --dht-entry-point=HOST:PORT Set host and port as an entry point to IPv4 DHT + network. + + Possible Values: HOST:PORT + Tags: #bittorrent + + --dht-file-path=PATH Change the IPv4 DHT routing table file to PATH. + + Possible Values: /path/to/file + Default: C:/Users//.cache/aria2/dht.dat + Tags: #bittorrent + + --enable-dht6[=true|false] Enable IPv6 DHT functionality. + Use --dht-listen-port option to specify port + number to listen on. See also --dht-listen-addr6 + option. + + Possible Values: true, false + Default: false + Tags: #basic, #bittorrent + + --dht-listen-addr6=ADDR Specify address to bind socket for IPv6 DHT. + It should be a global unicast IPv6 address of the + host. + + Tags: #basic, #bittorrent + + --dht-entry-point6=HOST:PORT Set host and port as an entry point to IPv6 DHT + network. + + Possible Values: HOST:PORT + Tags: #bittorrent + + --dht-file-path6=PATH Change the IPv6 DHT routing table file to PATH. + + Possible Values: /path/to/file + Default: C:/Users//.cache/aria2/dht6.dat + Tags: #bittorrent + + --bt-min-crypto-level=plain|arc4 Set minimum level of encryption method. + If several encryption methods are provided by a + peer, aria2 chooses the lowest one which satisfies + the given level. + + Possible Values: plain, arc4 + Default: plain + Tags: #bittorrent + + --bt-require-crypto[=true|false] If true is given, aria2 doesn't accept and + establish connection with legacy BitTorrent + handshake. Thus aria2 always uses Obfuscation + handshake. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-request-peer-speed-limit=SPEED If the whole download speed of every + torrent is lower than SPEED, aria2 temporarily + increases the number of peers to try for more + download speed. Configuring this option with your + preferred download speed can increase your + download speed in some cases. + You can append K or M(1K = 1024, 1M = 1024K). + + Possible Values: 0-* + Default: 50K + Tags: #bittorrent + + --bt-max-open-files=NUM Specify maximum number of files to open in + multi-file BitTorrent/Metalink downloads + globally. + + Possible Values: 1-* + Default: 100 + Tags: #bittorrent + + --bt-seed-unverified[=true|false] Seed previously downloaded files without + verifying piece hashes. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-hash-check-seed[=true|false] If true is given, after hash check using + --check-integrity option and file is complete, + continue to seed file. If you want to check file + and download it only when it is damaged or + incomplete, set this option to false. + This option has effect only on BitTorrent + download. + + Possible Values: true, false + Default: true + Tags: #bittorrent, #checksum + + --bt-max-peers=NUM Specify the maximum number of peers per torrent. + 0 means unlimited. + See also --bt-request-peer-speed-limit option. + + Possible Values: 0-* + Default: 55 + Tags: #bittorrent + + --bt-external-ip=IPADDRESS Specify the external IP address to use in + BitTorrent download and DHT. It may be sent to + BitTorrent tracker. For DHT, this option should + be set to report that local node is downloading + a particular torrent. This is critical to use + DHT in a private network. Although this function + is named 'external', it can accept any kind of IP + addresses. + + Possible Values: a numeric IP address + Tags: #bittorrent + + -O, --index-out=INDEX=PATH Set file path for file with index=INDEX. You can + find the file index using the --show-files option. + PATH is a relative path to the path specified in + --dir option. You can use this option multiple + times. + + Possible Values: INDEX=PATH + Tags: #bittorrent + + --bt-tracker-interval=SEC Set the interval in seconds between tracker + requests. This completely overrides interval value + and aria2 just uses this value and ignores the + min interval and interval value in the response of + tracker. If 0 is set, aria2 determines interval + based on the response of tracker and the download + progress. + + Possible Values: 0-* + Default: 0 + Tags: #bittorrent + + --bt-stop-timeout=SEC Stop BitTorrent download if download speed is 0 in + consecutive SEC seconds. If 0 is given, this + feature is disabled. + + Possible Values: 0-* + Default: 0 + Tags: #bittorrent + + --bt-prioritize-piece=head[=SIZE],tail[=SIZE] Try to download first and last + pieces of each file first. This is useful for + previewing files. The argument can contain 2 + keywords:head and tail. To include both keywords, + they must be separated by comma. These keywords + can take one parameter, SIZE. For example, if + head=SIZE is specified, pieces in the range of + first SIZE bytes of each file get higher priority. + tail=SIZE means the range of last SIZE bytes of + each file. SIZE can include K or M(1K = 1024, 1M = + 1024K). If SIZE is omitted, SIZE=1M is used. + + Possible Values: head[=SIZE], tail[=SIZE] + Tags: #bittorrent + + --bt-save-metadata[=true|false] Save metadata as .torrent file. This option has + effect only when BitTorrent Magnet URI is used. + The filename is hex encoded info hash with suffix + .torrent. The directory to be saved is the same + directory where download file is saved. If the + same file already exists, metadata is not saved. + See also --bt-metadata-only option. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-metadata-only[=true|false] Download metadata only. The file(s) described + in metadata will not be downloaded. This option + has effect only when BitTorrent Magnet URI is + used. See also --bt-save-metadata option. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-enable-lpd[=true|false] Enable Local Peer Discovery. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-lpd-interface=INTERFACE Use given interface for Local Peer Discovery. If + this option is not specified, the default + interface is chosen. You can specify interface + name and IP address. + + Possible Values: interface, IP address + Tags: #bittorrent + + --bt-tracker-timeout=SEC Set timeout in seconds. + + Possible Values: 1-600 + Default: 60 + Tags: #bittorrent + + --bt-tracker-connect-timeout=SEC Set the connect timeout in seconds to + establish connection to tracker. After the + connection is established, this option makes no + effect and --bt-tracker-timeout option is used + instead. + + Possible Values: 1-600 + Default: 60 + Tags: #bittorrent + + --dht-message-timeout=SEC Set timeout in seconds. + + Possible Values: 1-60 + Default: 10 + Tags: #bittorrent + + --on-bt-download-complete=COMMAND For BitTorrent, a command specified in + --on-download-complete is called after download + completed and seeding is over. On the other hand, + this option sets the command to be executed after + download completed but before seeding. + See --on-download-start option for the + requirement of COMMAND. + + Possible Values: /path/to/command + Tags: #advanced, #hook + + --bt-tracker=URI[,...] Comma separated list of additional BitTorrent + tracker's announce URI. These URIs are not + affected by --bt-exclude-tracker option because + they are added after URIs in --bt-exclude-tracker + option are removed. + + Possible Values: URI,... + Tags: #bittorrent + + --bt-exclude-tracker=URI[,...] Comma separated list of BitTorrent tracker's + announce URI to remove. You can use special value + '*' which matches all URIs, thus removes all + announce URIs. When specifying '*' in shell + command-line, don't forget to escape or quote it. + See also --bt-tracker option. + + Possible Values: URI,... or * + Tags: #bittorrent + + --bt-remove-unselected-file[=true|false] Removes the unselected files when + download is completed in BitTorrent. To + select files, use --select-file option. If + it is not used, all files are assumed to be + selected. Please use this option with care + because it will actually remove files from + your disk. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-detach-seed-only[=true|false] + Exclude seed only downloads when counting + concurrent active downloads (See -j option). + This means that if -j3 is given and this option + is turned on and 3 downloads are active and one + of those enters seed mode, then it is excluded + from active download count (thus it becomes 2), + and the next download waiting in queue gets + started. But be aware that seeding item is still + recognized as active download in RPC method. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-force-encryption[=true|false] + Requires BitTorrent message payload encryption + with arc4. This is a shorthand of + --bt-require-crypto --bt-min-crypto-level=arc4. + If true is given, deny legacy BitTorrent + handshake and only use Obfuscation handshake and + always encrypt message payload. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + --bt-enable-hook-after-hash-check[=true|false] Allow hook command invocation + after hash check (see -V option) in BitTorrent + download. By default, when hash check succeeds, + the command given by --on-bt-download-complete + is executed. To disable this action, give false + to this option. + + Possible Values: true, false + Default: true + Tags: #bittorrent + + --bt-load-saved-metadata[=true|false] + Before getting torrent metadata from DHT when + downloading with magnet link, first try to read + file saved by --bt-save-metadata option. If it is + successful, then skip downloading metadata from + DHT. + + Possible Values: true, false + Default: false + Tags: #bittorrent + + -M, --metalink-file=METALINK_FILE The file path to the .meta4 and .metalink + file. Reads input from stdin when '-' is + specified. + + Possible Values: /path/to/file, - + Tags: #basic, #metalink + + --metalink-version=VERSION The version of the file to download. + + Tags: #metalink + + --metalink-language=LANGUAGE The language of the file to download. + + Tags: #metalink + + --metalink-os=OS The operating system of the file to download. + + Tags: #metalink + + --metalink-location=LOCATION[,...] The location of the preferred server. + A comma-delimited list of locations is + acceptable. + + Tags: #metalink + + --follow-metalink=true|false|mem If true or mem is specified, when a file + whose suffix is .meta4 or .metalink, or content + type of application/metalink4+xml or + application/metalink+xml is downloaded, aria2 + parses it as a metalink file and downloads files + mentioned in it. + If mem is specified, a metalink file is not + written to the disk, but is just kept in memory. + If false is specified, the .metalink file is + downloaded to the disk, but is not parsed as a + metalink file and its contents are not + downloaded. + + Possible Values: true, mem, false + Default: true + Tags: #metalink + + --metalink-preferred-protocol=PROTO Specify preferred protocol. Specify 'none' + if you don't have any preferred protocol. + + Possible Values: http, https, ftp, none + Default: none + Tags: #metalink + + --metalink-enable-unique-protocol[=true|false] If true is given and several + protocols are available for a mirror in a metalink + file, aria2 uses one of them. + Use --metalink-preferred-protocol option to + specify the preference of protocol. + + Possible Values: true, false + Default: true + Tags: #metalink + + --metalink-base-uri=URI Specify base URI to resolve relative URI in + metalink:url and metalink:metaurl element in a + metalink file stored in local disk. If URI points + to a directory, URI must end with '/'. + + Tags: #metalink + +Refer to man page for more information. diff --git a/docs/compatibility/goldens/cli/upstream/version.txt b/docs/compatibility/goldens/cli/upstream/version.txt new file mode 100644 index 0000000..9710599 --- /dev/null +++ b/docs/compatibility/goldens/cli/upstream/version.txt @@ -0,0 +1,25 @@ +aria2 version 1.37.0 +Copyright (C) 2006, 2019 Tatsuhiro Tsujikawa + +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. + +** Configuration ** +Enabled Features: Async DNS, BitTorrent, Firefox3 Cookie, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP +Hash Algorithms: sha-1, sha-224, sha-256, sha-384, sha-512, md5, adler32 +Libraries: zlib/1.3 expat/2.5.0 sqlite3/3.43.1 GMP/6.3.0 c-ares/1.19.1 libssh2/1.11.0 +Compiler: mingw-w64 8.0.0 (alpha) / gcc 10-win32 20220113 + built by x86_64-pc-linux-gnu + targeting x86_64-w64-mingw32 + on Nov 15 2023 11:17:49 +System: Windows 6.2 (x86_64) (6.2) + +Report bugs to https://github.com/aria2/aria2/issues +Visit https://aria2.github.io/ diff --git a/docs/deployment/README.md b/docs/deployment/README.md new file mode 100644 index 0000000..f4246d6 --- /dev/null +++ b/docs/deployment/README.md @@ -0,0 +1,223 @@ +# Deployment Guide + +This is the concrete deployment guide for the current `aria2-rust-pro` +repository state. It covers the two deployable paths that exist today: + +- native host deployment from a local Cargo build +- local Docker deployment from `docker/Dockerfile` + +For migration-specific advice, start with [../migration/README.md](../migration/README.md). + +## Current Deployment Facts + +- Native host installs are source-build-first today. +- The self-hosted Gitea `v1.0.0` release carries the first Windows archive, + Docker image tar, manifests, and checksum assets. +- Docker deployment is still local-build-first or tar-import-first; there is + no documented published container registry tag yet. +- Native RPC startup requires `--enable-rpc` on the command line. Putting + `enable-rpc=true` only in the config file is not enough to switch the CLI + into the long-running RPC server path. +- `--daemon` currently selects the RPC-daemon command surface; it should not be + treated as a service-manager replacement or as proof of POSIX background + forking. + +## Native Host Deployment + +### 1. Build the binary + +```powershell +cargo build --release -p aria2-rust-pro-cli --bin aria2-rust-pro +``` + +The resulting binary is: + +- Windows: `target\release\aria2-rust-pro.exe` +- Linux: `target/release/aria2-rust-pro` + +### 2. Prepare the runtime directories + +On Windows, a simple starting layout is: + +```powershell +$Root = "C:/ProgramData/aria2-rust-pro" +New-Item -ItemType Directory -Force "$Root", "$Root/downloads", "$Root/state" | Out-Null +Copy-Item .\docs\deployment\examples\aria2.conf "$Root/aria2.conf" +``` + +On Linux, the same layout works under `/var/lib/aria2-rust-pro` or +`/srv/aria2-rust-pro`. + +Edit the copied config so that `dir=`, `input-file=`, and `save-session=` point +at your real directories, and replace `rpc-secret=` with a strong private value +before enabling RPC. A ready-to-edit template lives at +[examples/aria2.conf](examples/aria2.conf). + +### 3. Validate the config + +```powershell +.\target\release\aria2-rust-pro.exe --dry-run --conf-path C:/ProgramData/aria2-rust-pro/aria2.conf +``` + +This should exit cleanly with no config-parse error. + +### 4. Start the RPC process + +For a direct foreground launch: + +```powershell +.\target\release\aria2-rust-pro.exe --conf-path C:/ProgramData/aria2-rust-pro/aria2.conf --enable-rpc +``` + +For Linux service managers, keep the process in the foreground and let the +supervisor own restart behavior. A sample unit file lives at +[examples/aria2-rust-pro.service](examples/aria2-rust-pro.service). + +### 5. Verify the deployment + +Version: + +```powershell +.\target\release\aria2-rust-pro.exe --version +``` + +RPC probe: + +```powershell +xh post http://127.0.0.1:6800/jsonrpc jsonrpc=2.0 id=deploy method=aria2.getVersion params:='["token:replace-with-a-strong-rpc-secret"]' +``` + +Session file presence: + +```powershell +Test-Path C:/ProgramData/aria2-rust-pro/state/aria2.session +``` + +## Docker Deployment + +`docker/Dockerfile`, `docker/entrypoint.sh`, and the Cargo-native Docker smoke +commands define the current Docker deployment shape. Use the daemon-free and +daemon-backed smokes below to verify the current workspace snapshot before +calling a local deployment ready. + +When the daemon is available, a portable local image tar can also be staged +with `xtask docker export-local`; `scripts/docker/export-local.ps1` remains a +compatibility wrapper. + +### 1. Prepare the env file + +Copy [../../docker/.env.example](../../docker/.env.example) to `docker/.env` +and replace the example values: + +```powershell +Copy-Item .\docker\.env.example .\docker\.env +``` + +Set a strong `RPC_SECRET` before starting compose. The compose file refuses an +empty secret and binds the host RPC port to `127.0.0.1` by default; widen +`RPC_BIND_ADDRESS` only when the host network is trusted. + +### 2. Review the persistent volume paths + +`docker/docker-compose.yml` currently mounts: + +- `../.local/docker/config` to `/config` +- `../.local/docker/downloads` to `/downloads` + +If your persistent data lives elsewhere, change those bind mounts before the +first start. + +### 3. Build and start the container + +```powershell +docker compose --env-file docker/.env -f docker/docker-compose.yml up -d --build +``` + +This builds `aria2-rust-pro:local`, starts the `aria2-rust-pro` container, and +exposes the configured RPC and listen ports. + +### 4. Inspect the generated runtime config + +```powershell +docker exec aria2-rust-pro sh -lc "sed 's/^rpc-secret=.*/rpc-secret=/' /run/aria2-rust-pro/aria2.generated.conf" +``` + +The entrypoint: + +- copies `/defaults/aria2.conf` to `/config/aria2.conf` only when the base file + does not already exist +- ensures `/config/aria2.session` exists +- appends env-derived overrides into + `/run/aria2-rust-pro/aria2.generated.conf` + +### 5. Verify RPC and logs + +Logs: + +```powershell +docker compose --env-file docker/.env -f docker/docker-compose.yml logs -f +``` + +Version inside the container: + +```powershell +docker exec aria2-rust-pro aria2c --version +``` + +RPC probe from the host: + +```powershell +xh post http://127.0.0.1:6800/jsonrpc jsonrpc=2.0 id=deploy method=aria2.getVersion params:='["token:replace-with-a-strong-rpc-secret"]' +``` + +### Current Docker behavior + +The current entrypoint does more than simple port/env mapping: + +- it seeds `bt-tracker=` from `docker/defaults/bt-tracker.txt` +- `SPECIAL_MODE=move` or `SPECIAL_MODE=rclone` appends + `on-download-complete=...` +- `UPDATE_TRACKERS=true` runs the bundled tracker updater script + +Those entrypoint behaviors are covered by both `xtask docker smoke-local` and +the daemon-backed `xtask docker smoke`. The live container path now boots, +serves RPC, and keeps those Docker-specific config rewrites active in +`/run/aria2-rust-pro/aria2.generated.conf`. + +## Smoke Commands + +Use the canonical Cargo-native smoke commands after any packaging or deployment +change: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke --tag aria2-rust-pro:smoke +``` + +`xtask docker smoke-local` proves the entrypoint/config-generation path without +a Docker daemon. `xtask docker smoke` builds the image and probes live JSON-RPC +inside a running container. + +The PowerShell scripts remain compatibility wrappers with the same local +options: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\docker\smoke-local.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\docker\smoke.ps1 -Tag aria2-rust-pro:smoke +``` + +The daemon-backed wrapper forwards `-Build`, `-HostRpcPort`, `-RpcSecret`, and +`-SpecialMode` to the matching `xtask docker smoke` options. + +## Known Deployment Limits Today + +- No documented published image tag yet; Docker deployment is still + local-build-first or tar-import-first from the release asset. +- The self-hosted Gitea `v1.0.0` release includes the first Windows archive and + Docker tar assets; a broader multi-platform archive matrix is still future + work. +- The Docker entrypoint now applies tracker snapshot, tracker-update, and + special-mode hook rewrites, but this is still a local-build-first deployment + lane rather than a published image-release matrix. +- The repo ships a sample systemd unit, but not a first-party Windows service + wrapper or installer. diff --git a/docs/deployment/examples/aria2-rust-pro.service b/docs/deployment/examples/aria2-rust-pro.service new file mode 100644 index 0000000..635bb1f --- /dev/null +++ b/docs/deployment/examples/aria2-rust-pro.service @@ -0,0 +1,18 @@ +[Unit] +Description=aria2-rust-pro RPC service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=aria2 +Group=aria2 +WorkingDirectory=/var/lib/aria2-rust-pro +ExecStart=/opt/aria2-rust-pro/bin/aria2-rust-pro --conf-path /etc/aria2-rust-pro/aria2.conf --enable-rpc +Restart=on-failure +RestartSec=2 +NoNewPrivileges=true +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target diff --git a/docs/deployment/examples/aria2.conf b/docs/deployment/examples/aria2.conf new file mode 100644 index 0000000..82513f4 --- /dev/null +++ b/docs/deployment/examples/aria2.conf @@ -0,0 +1,20 @@ +# Minimal native deployment template for aria2-rust-pro. +# Edit the paths and rpc-secret before first use. + +dir=/srv/aria2-rust-pro/downloads +input-file=/srv/aria2-rust-pro/state/aria2.session +save-session=/srv/aria2-rust-pro/state/aria2.session +save-session-interval=60 +continue=true +split=5 +max-connection-per-server=16 +min-split-size=1K +piece-length=1K +enable-rpc=true +rpc-listen-port=6800 +rpc-secret=replace-with-a-strong-rpc-secret +listen-port=6888 +dht-listen-port=6888 +disable-ipv6=true +disk-cache=64M +check-certificate=true diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 0000000..cc969a1 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,187 @@ +# Migration Guide + +This guide reflects the current active modernization state from +`GOAL-modern.md` and `progress.md` for `aria2-rust-pro`. It is written for two +existing audiences: + +- users running the current `Aria2-Pro-Core` host binary directly +- users running the current `Aria2-Pro-Docker` container shape + +The short version is: + +- aria2-style config syntax, session files, and RPC clients are intended to + carry forward +- the native host binary is now `aria2-rust-pro`, not a published + `aria2-pro-core` drop-in tarball +- the Docker image keeps the legacy env variable names and now maps the current + Pro Docker knobs through the entrypoint/runtime config path + +## Before You Cut Over + +Make a copy of the three things that matter before any migration: + +1. your current `aria2.conf` +2. your `aria2.session` plus any adjacent `.aria2` control files +3. the exact runtime command or compose file you use today + +For Docker users, also record the current bind mounts or named volumes so that +you know where `/config` and `/downloads` live on disk. + +## Compatibility Snapshot + +| Surface | Current `aria2-rust-pro` status | Migration meaning | +| --- | --- | --- | +| aria2-style config file | compatible target; current parser/runtime covers the common Pro/Core and Docker paths used here | keep your existing config and validate it with `--dry-run` before switching traffic | +| Session and input files | current session persistence is implemented and covered by tests | keep existing session paths; do not delete `aria2.session` during cutover | +| JSON-RPC and XML-RPC | current RPC surface is implemented enough for representative aria2 clients | reuse the same client URLs and tokens after you verify the new port and secret | +| Native packaging | the self-hosted Gitea `v1.0.0` release includes a Windows archive plus checksums, while broader platform archives remain future work | use the release archive where it matches your host, otherwise plan for a local Cargo build or your own packaged artifact | +| Docker env names | the current Pro Docker env set is accepted and mapped by the entrypoint | keep the same env names, then verify the generated runtime config before cutover | + +## From `Aria2-Pro-Core` + +### What stays the same + +- Keep your existing `aria2.conf` format. +- Keep your current `aria2.session`. +- Keep the Pro/Core option deltas that already exist in this rewrite, including + the relaxed split and retry knobs tracked in the compatibility ledger. + +### What changes + +| Area | Old Core habit | `aria2-rust-pro` now | +| --- | --- | --- | +| Binary name | usually `aria2c` | native host binary is `aria2-rust-pro` | +| Install flow | download a release tarball and move `aria2c` into place | build with Cargo or deploy your own packaged artifact | +| RPC startup | config plus your old service wrapper | native startup still needs `--enable-rpc` on the command line to enter the long-running RPC server path | +| Backgrounding | old wrappers often daemonized the process | do not rely on `--daemon` as a POSIX fork; supervise the foreground process with systemd, NSSM, Task Scheduler, or another service manager | + +### Recommended host cutover + +1. Build the new binary without overwriting the old one yet: + + ```powershell + cargo build --release -p aria2-rust-pro-cli --bin aria2-rust-pro + ``` + +2. Stage a new config root and copy your current config/session into it. + +3. Validate the config before swapping any service definition: + + ```powershell + .\target\release\aria2-rust-pro.exe --dry-run --conf-path C:/ProgramData/aria2-rust-pro/aria2.conf + ``` + +4. Update your service or launcher to point at the new binary and keep + `--enable-rpc` on the command line: + + ```powershell + .\target\release\aria2-rust-pro.exe --conf-path C:/ProgramData/aria2-rust-pro/aria2.conf --enable-rpc + ``` + +5. Probe RPC before you disable the old service: + + ```powershell +xh post http://127.0.0.1:6800/jsonrpc jsonrpc=2.0 id=migrate method=aria2.getVersion params:='["token:replace-with-a-strong-rpc-secret"]' + ``` + +If your surrounding scripts still hardcode `aria2c`, keep the old binary in +place until you have either updated those scripts or installed your own wrapper +or symlink that points `aria2c` to `aria2-rust-pro`. + +## From `Aria2-Pro-Docker` + +### What carries forward + +- `/config/aria2.conf` remains the base config path. +- `/config/aria2.session` remains the session file path. +- `/downloads` remains the default download root. +- the container still accepts the familiar Pro Docker env variable names + +### What is different from the old Docker behavior + +| Env or behavior | Old Pro Docker expectation | `aria2-rust-pro` now | +| --- | --- | --- | +| `RPC_SECRET` | old images often rewrote `aria2.conf` in place and could expose compatibility fallbacks | when set, it appends `rpc-secret` and `rpc-listen-all=true` into the generated runtime config; when empty, the entrypoint warns and keeps RPC loopback-only unless your base config overrides it | +| `RPC_PORT` | runtime env + config rewrite | mapped into `/run/aria2-rust-pro/aria2.generated.conf` | +| `LISTEN_PORT` | runtime env + config rewrite | mapped into generated config for both `listen-port` and `dht-listen-port` | +| `DISK_CACHE` | runtime env + config rewrite | mapped into generated config | +| `IPV6_MODE` | runtime env + config rewrite | mapped into generated config as `disable-ipv6=true/false` | +| `UPDATE_TRACKERS` | functional startup/runtime knob | supported by the entrypoint; when enabled it runs the bundled tracker updater against the generated config | +| `CUSTOM_TRACKER_URL` | functional tracker-updater input | supported by the entrypoint as the tracker updater source URL | +| `SPECIAL_MODE` | functional mode hook | supported by the entrypoint for `move` and `rclone`; it copies default scripts and appends `on-download-complete=` to the generated config | +| Config mutation | old image families often rewrote `/config/aria2.conf` directly | this image copies `/config/aria2.conf` only when missing, then writes overrides to `/run/aria2-rust-pro/aria2.generated.conf` | + +### Recommended Docker staging flow + +1. Stop the old container without deleting the existing `/config` and + `/downloads` data. + +2. Copy `docker/.env.example` to `docker/.env` and set your real values. + +3. If your current data lives somewhere other than the compose defaults, adjust + the volume paths in `docker/docker-compose.yml` before first start. + +4. Build and start the replacement container: + + ```powershell + docker compose --env-file docker/.env -f docker/docker-compose.yml up -d --build + ``` + +5. Confirm that the container preserved the base config and generated the + runtime overlay: + + ```powershell + docker exec aria2-rust-pro sh -lc "test -f /config/aria2.conf && sed 's/^rpc-secret=.*/rpc-secret=/' /run/aria2-rust-pro/aria2.generated.conf" + ``` + +6. Probe RPC from the host: + + ```powershell +xh post http://127.0.0.1:6800/jsonrpc jsonrpc=2.0 id=migrate method=aria2.getVersion params:='["token:replace-with-a-strong-rpc-secret"]' + ``` + +### Current Docker status + +The Docker deployment lane is now materially real: + +- `xtask docker smoke-local` verifies entrypoint/config generation without a + daemon +- `xtask docker smoke` builds the image, starts the container, verifies + `aria2c --version`, checks the generated runtime config, and probes live + JSON-RPC +- `xtask docker export-local` can stage a portable image tar under +`dist\docker\` when the daemon is reachable + +The matching `scripts/docker/*.ps1` files remain thin compatibility wrappers +for existing local workflows. + +The entrypoint now: + +- seeds a bundled `bt-tracker=` snapshot when the config leaves it empty +- honors `UPDATE_TRACKERS` and `CUSTOM_TRACKER_URL` through the bundled tracker + updater +- honors `SPECIAL_MODE=move` and `SPECIAL_MODE=rclone` by materializing the + matching hook scripts and appending `on-download-complete=...` + +That makes the safe migration posture for existing Pro Docker users: + +- stage the new image with the same `/config` and `/downloads` data +- inspect `/run/aria2-rust-pro/aria2.generated.conf` after first boot +- probe RPC before switching user traffic + +Inside the container, `/usr/local/bin/aria2c` is a symlink to +`/usr/local/bin/aria2-rust-pro`, so tools that `docker exec ... aria2c ...` +still work after the image swap. + +## Rollback + +Keep rollback boring: + +1. stop the new host service or container +2. restore the old binary or old image reference +3. put back the backed-up `aria2.conf` and `aria2.session` +4. start the old deployment shape again + +Rollback is much easier if you do not overwrite the old host binary path or +delete the old Docker volumes until the RPC probe and a small real download +both pass on the new deployment. diff --git a/docs/perf/README.md b/docs/perf/README.md new file mode 100644 index 0000000..a3a352b --- /dev/null +++ b/docs/perf/README.md @@ -0,0 +1,21 @@ +# Performance Evidence + +Performance evidence starts here. This directory records benchmark design notes and measured results. + +As of the Phase 3 Cargo-native workflow push, the canonical report-refresh +entrypoint is: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- perf collect-local-comparison +``` + +`scripts/perf/collect_local_comparison.ps1` remains available as a thin +compatibility shim for existing local workflows. + +Current active performance evidence files: + +- `optimization-ranking.md` +- `rpc-pressure-evidence.md` +- `criterion-benchmarks.md` +- `local-comparison.md` +- `size-evidence.md` diff --git a/docs/perf/criterion-benchmarks.md b/docs/perf/criterion-benchmarks.md new file mode 100644 index 0000000..6aadc85 --- /dev/null +++ b/docs/perf/criterion-benchmarks.md @@ -0,0 +1,176 @@ +# Criterion Benchmarks + +This file records the Criterion-backed benchmark suite for +`aria2-rust-pro`. + +## Purpose + +This project needs a repeatable benchmark surface in addition to assertion-style +regression tests. This suite converts the existing synthetic BT/RPC pressure +patterns into Criterion benchmarks so later tranches can compare revisions and +resource-limit tuning with the same workload shape. + +## Bench Target + +- crate: `crates/aria2-rust-pro-tests` +- bench: `rpc_pressure` + +## Current Coverage + +The current suite measures nine deterministic scenarios: + +1. `rpc_tell_status_pressure` + - 64-task and 128-task BT-like dispatcher loads + - repeated `aria2.tellStatus` calls under runtime tick churn +2. `rpc_mixed_pressure` + - 96-task and 192-task BT-like dispatcher loads + - mixed `aria2.tellStatus`, `aria2.tellActive`, and `aria2.tellGlobalStat` + batches +3. `runtime_snapshot_pressure` + - 64 / 128 / 256 download in-memory engine setups + - repeated scheduler runs plus `runtime_instrumentation_snapshot()` capture +4. `rpc_speed_limit_pressure` + - 32-task and 64-task BT-like dispatcher loads + - active `changeGlobalOption` / `changeOption` speed caps + - repeated `tellStatus` plus `tellGlobalStat` under clamped runtime speeds +5. `scheduler_backpressure_pressure` + - 64-task and 128-task in-memory engine setups + - mixed active/waiting/error groups + - repeated scheduler runs plus runtime instrumentation under differing + `disk-cache` budgets +6. `live_http_transfer_contention_pressure` + - loopback live HTTP transfer via `ReqwestHttpConnector` and + `ConnectorBackedDownloader` + - 16 KiB segmented HTTP downloads that force bootstrap `206` plus follow-up + range requests + - comparison between a loose-cap runtime and a tight + `max-overall-download-limit` runtime so execution-layer throttling is + measured instead of only inferred from RPC snapshots +7. `live_http_multi_download_contention_pressure` + - three concurrent loopback live HTTP downloads + - each download still uses the existing CLI/runtime wiring, but all three + contend against one local segment server at once + - compares loose-cap vs tight-cap runtime configuration under aggregate + multi-download contention +8. `rpc_shared_runtime_fairness_pressure` + - three-way and four-way BT-like dispatcher loads inside one runtime + - one constrained gid completes, then the remaining active gids are expected + to inherit a larger share of the global speed cap + - verifies same-runtime cross-download rebalancing rather than only + per-download segment throttling or separate parallel invocations +9. `live_http_shared_runtime_multi_download_pressure` + - three loopback live HTTP downloads registered into one invocation/runtime + - uses the shared CLI runtime path rather than separate parallel invocations + - compares loose-cap vs tight-cap runtime configuration for same-runtime live + transfer behavior + - now also includes a six-download `tight_cap_6way` variant so shared-runtime + cap behavior is measured beyond the original 3-download / 16 KiB shape + - now also includes `cache_pressure_6way_256k`, which raises per-download + transfer size while constraining `disk-cache` so shared-runtime cache + pressure is represented in the same benchmark family + +## Commands + +Canonical local report refresh: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- perf collect-local-comparison +pwsh ./scripts/perf/collect_local_comparison.ps1 +``` + +Compile-only verification: + +```powershell +rtk cargo bench --manifest-path .\Cargo.toml -p aria2-rust-pro-tests --bench rpc_pressure --no-run +``` + +Short local run: + +```powershell +rtk cargo bench --manifest-path .\Cargo.toml -p aria2-rust-pro-tests --bench rpc_pressure -- --sample-size 10 --measurement-time 0.05 --warm-up-time 0.05 +``` + +## First Short Run Snapshot + +Short local run on this workspace produced the following indicative timings: + +- `rpc_tell_status_pressure/tell_status/64` + - `1.3318 ms .. 1.3459 ms` + - throughput `190.21 Kelem/s .. 192.22 Kelem/s` +- `rpc_tell_status_pressure/tell_status/128` + - `2.8581 ms .. 2.8726 ms` + - throughput `178.24 Kelem/s .. 179.14 Kelem/s` +- `rpc_mixed_pressure/mixed_rpc/96` + - `59.918 us .. 64.561 us` + - throughput `278.81 Kelem/s .. 300.41 Kelem/s` +- `rpc_mixed_pressure/mixed_rpc/192` + - `78.663 us .. 83.252 us` + - throughput `216.21 Kelem/s .. 228.82 Kelem/s` +- `runtime_snapshot_pressure/runtime_snapshot/64` + - `8.3619 us .. 9.0689 us` +- `runtime_snapshot_pressure/runtime_snapshot/128` + - `15.452 us .. 17.835 us` +- `runtime_snapshot_pressure/runtime_snapshot/256` + - `31.072 us .. 33.087 us` +- `rpc_speed_limit_pressure/speed_limit/32` + - `64.896 us .. 66.191 us` + - throughput `181.29 Kelem/s .. 184.91 Kelem/s` +- `rpc_speed_limit_pressure/speed_limit/64` + - `90.733 us .. 93.649 us` + - throughput `128.14 Kelem/s .. 132.26 Kelem/s` +- `scheduler_backpressure_pressure/backpressure/64` + - `10.917 us .. 12.478 us` + - throughput `5.1290 Melem/s .. 5.8625 Melem/s` +- `scheduler_backpressure_pressure/backpressure/128` + - `19.792 us .. 21.039 us` + - throughput `6.0839 Melem/s .. 6.4673 Melem/s` +- `live_http_transfer_contention_pressure/live_http_transfer/loose_cap` + - `38.640 ms .. 39.143 ms` + - throughput `408.76 KiB/s .. 414.07 KiB/s` +- `live_http_transfer_contention_pressure/live_http_transfer/tight_cap` + - `41.793 ms .. 48.988 ms` + - throughput `326.61 KiB/s .. 382.83 KiB/s` + +These are not cross-machine release numbers. They are the first local benchmark +local benchmark anchor so later tuning and regressions can be compared against a +stable local benchmark driver. The new live HTTP group is still loopback-local rather +than internet-realistic, but it now exercises the segmented execution path with +real socket I/O instead of only scheduler-facing synthetic churn. + +- `live_http_multi_download_contention_pressure/multi_live_http_transfer/loose_cap` + - `105.63 ms .. 106.46 ms` + - throughput `450.86 KiB/s .. 454.43 KiB/s` +- `live_http_multi_download_contention_pressure/multi_live_http_transfer/tight_cap` + - `105.13 ms .. 105.52 ms` + - throughput `454.90 KiB/s .. 456.56 KiB/s` +- `rpc_shared_runtime_fairness_pressure/shared_runtime_fairness/three_way_rebalance` + - `70.687 us .. 72.082 us` + - throughput `166.48 Kelem/s .. 169.76 Kelem/s` +- `rpc_shared_runtime_fairness_pressure/shared_runtime_fairness/four_way_rebalance` + - `95.853 us .. 96.893 us` + - throughput `165.13 Kelem/s .. 166.92 Kelem/s` +- `live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/loose_cap` + - `107.57 ms .. 108.23 ms` + - throughput `443.49 KiB/s .. 446.23 KiB/s` +- `live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/tight_cap` + - `118.77 ms .. 120.35 ms` + - throughput `398.83 KiB/s .. 404.14 KiB/s` +- `live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/tight_cap_6way` + - `234.36 ms .. 236.30 ms` + - throughput `406.26 KiB/s .. 409.63 KiB/s` +- `live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/cache_pressure_6way_256k` + - `96.98 ms .. 99.12 ms` + - throughput `15.133 MiB/s .. 15.466 MiB/s` + +## Intended Next Extension + +This suite now mixes synthetic pressure and local loopback transfer. The next +tranche should attach: + +- comparisons against the C++ aria2 reference and current Pro Core +- further larger or more realistic same-runtime live transfer workloads beyond + the new 6-download tight-cap and cache-pressure anchors +- stronger disk-cache and backpressure scenarios with larger transfer/runtime + contention +- original C++ aria2 / current Pro Core comparison notes +- memory and file-descriptor measurements recorded beside benchmark output diff --git a/docs/perf/local-comparison.md b/docs/perf/local-comparison.md new file mode 100644 index 0000000..02a4c9d --- /dev/null +++ b/docs/perf/local-comparison.md @@ -0,0 +1,87 @@ +# Local Comparison + +Generated: 2026-05-28 12:08:14 +00:00 + +## Environment + +- Repo root: +- Bench executable: \target\release\deps\rpc_pressure-386f010a5bd10aaf.exe +- Manifest: \Cargo.toml +- Bench process exit code: unavailable in this refresh +- Peak working set during local bench run: unavailable in this refresh +- Peak handle count during local bench run: unavailable in this refresh + +## Reference Artifact Detection + +- Rust CLI local executable: \target\release\aria2-rust-pro.exe +- Original aria2 local executable: \dist\upstream\aria2-1.37.0-win-64bit-build1\aria2-1.37.0-win-64bit-build1\aria2c.exe +- Original aria2 version summary: aria2 version 1.37.0; Enabled Features: Async DNS, BitTorrent, Firefox3 Cookie, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP; Compiler: mingw-w64 8.0.0 (alpha) / gcc 10-win32 20220113; built by x86_64-pc-linux-gnu; targeting x86_64-w64-mingw32; on Nov 15 2023 11:17:49 +- Current Pro Core local executable: \build\pro-core\windows-x64-mingw-30403\stage\aria2-pro-core-1.37.0+pro.20260523-windows-x64-mingw\aria2c.exe +- Current Pro Core version summary: aria2 version 1.37.0; Enabled Features: Async DNS, BitTorrent, Firefox3 Cookie, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP; Compiler: mingw-w64 15.0.0 (alpha) / gcc 16.1.0; built by x86_64-w64-mingw32; on May 23 2026 15:48:03; Pro Build: 1.37.0+pro.20260523; Pro Commit: 64365cf3cdca441ab710d53be8e4ded37a9b3d7e +- Comparison status: Rust local benchmarks and expanded same-host loopback HTTP comparisons were executed now across Rust, current Pro Core, and original upstream aria2 on this Windows host. + +## Same-Host Local HTTP Transfer Comparisons + +- Payload bytes: 8388608 +- Payload SHA256: BDF23837181F5808331800C1AE2B4F7D7A839536B10D58491471C50DDE23833A + +### single_file_split1 + +- One loopback HTTP file with split=1 and max-connection-per-server=1 + +| Binary | Exit code | Samples | Median | Spread | Output count | Output bytes | SHA256 all match | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Rust CLI | 0 | 5 | 132.53 ms | 120.16 .. 138.76 ms | 1 | 8388608 | true | +| Pro Core | 0 | 5 | 199.34 ms | 89.93 .. 211.04 ms | 1 | 8388608 | true | +| Upstream aria2 | 0 | 5 | 181.67 ms | 172.38 .. 202.26 ms | 1 | 8388608 | true | + +### segmented_single_file + +- One loopback HTTP file with split=4 and max-connection-per-server=4 + +| Binary | Exit code | Samples | Median | Spread | Output count | Output bytes | SHA256 all match | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Rust CLI | 0 | 5 | 125.49 ms | 121.33 .. 132.76 ms | 1 | 8388608 | true | +| Pro Core | 0 | 5 | 209.49 ms | 208.44 .. 1197.45 ms | 1 | 8388608 | true | +| Upstream aria2 | 0 | 5 | 174.51 ms | 171.90 .. 194.46 ms | 1 | 8388608 | true | + + +## Rust Criterion Summary + +| Benchmark | Mean | 95% CI | +| --- | --- | --- | +| bt_visibility_pressure/bt_visibility/32 | 910.409 us | 905.654 us .. 915.190 us | +| bt_visibility_pressure/bt_visibility/64 | 1.8430 ms | 1.8378 ms .. 1.8490 ms | +| live_http_multi_download_contention_pressure/multi_live_http_transfer/loose_cap | 11.6320 ms | 11.5494 ms .. 11.7368 ms | +| live_http_multi_download_contention_pressure/multi_live_http_transfer/tight_cap | 11.5679 ms | 11.5103 ms .. 11.6136 ms | +| live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/cache_pressure_6way_256k | 7.2873 ms | 7.1368 ms .. 7.4519 ms | +| live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/loose_cap | 11.8141 ms | 11.6959 ms .. 11.9347 ms | +| live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/tight_cap | 11.8461 ms | 11.7319 ms .. 11.9568 ms | +| live_http_shared_runtime_multi_download_pressure/shared_runtime_live_http_transfer/tight_cap_6way | 12.7498 ms | 12.6485 ms .. 12.8552 ms | +| live_http_transfer_contention_pressure/live_http_transfer/loose_cap | 11.6614 ms | 11.4932 ms .. 11.8646 ms | +| live_http_transfer_contention_pressure/live_http_transfer/tight_cap | 10.9603 ms | 10.8586 ms .. 11.0562 ms | +| rpc_mixed_pressure/mixed_rpc/192 | 135.092 us | 134.021 us .. 136.230 us | +| rpc_mixed_pressure/mixed_rpc/96 | 84.114 us | 82.923 us .. 85.222 us | +| rpc_shared_runtime_fairness_pressure/shared_runtime_fairness/four_way_rebalance | 88.653 us | 87.023 us .. 90.329 us | +| rpc_shared_runtime_fairness_pressure/shared_runtime_fairness/three_way_rebalance | 66.833 us | 65.539 us .. 68.357 us | +| rpc_speed_limit_pressure/speed_limit/32 | 60.370 us | 59.997 us .. 60.762 us | +| rpc_speed_limit_pressure/speed_limit/64 | 90.078 us | 87.568 us .. 93.803 us | +| rpc_tell_status_pressure/tell_status/128 | 2.6805 ms | 2.6576 ms .. 2.7154 ms | +| rpc_tell_status_pressure/tell_status/64 | 1.3265 ms | 1.3112 ms .. 1.3412 ms | +| runtime_snapshot_pressure/runtime_snapshot/128 | 24.534 us | 23.895 us .. 25.183 us | +| runtime_snapshot_pressure/runtime_snapshot/256 | 52.193 us | 48.362 us .. 59.051 us | +| runtime_snapshot_pressure/runtime_snapshot/64 | 13.701 us | 13.281 us .. 14.100 us | +| scheduler_backpressure_pressure/backpressure/128 | 33.341 us | 33.017 us .. 33.630 us | +| scheduler_backpressure_pressure/backpressure/64 | 18.659 us | 18.064 us .. 19.492 us | + +## Notes + +- This report is a local comparison anchor, not a cross-host release benchmark. +- Memory evidence here is peak working set observed during the benchmark process. Peak handle count is currently only recorded when the runtime can provide it. +- The same-host HTTP comparison now covers a narrow single-connection case (split=1) plus a segmented single-file case (split=4) on the same local loopback server model. +- Each binary/scenario pair now performs one untimed warmup launch followed by repeated timed samples; the side-by-side table reports median elapsed plus fastest..slowest spread so single-run host noise is less likely to dominate the comparison. +- The same-host driver now avoids coarse sleep quantization in its short-process timing and loopback accept path, and it keeps each scenario on one warmed loopback server/port across the timed sample set so Windows first-port cold-path stalls do not dominate the Rust rows. +- Small connector-side wins such as no-proxy reqwest client reuse are still better validated through the direct `ARIA2_RUST_PRO_HTTP_TIMING` breakdown than by whole-process elapsed tables alone. +- The same-host side-by-side table is strongest as a local regression anchor. Some non-Rust reference rows can still pick up host-local cold-path noise, so this report should not be treated as a precision benchmark without corroborating direct runs. +- Shared-runtime multi-download pressure is still represented here through the Rust Criterion anchors rather than the side-by-side table. +- The upstream Windows reference now comes from the official aria2 1.37.0 release artifact downloaded into `\dist\upstream\`. diff --git a/docs/perf/optimization-ranking.md b/docs/perf/optimization-ranking.md new file mode 100644 index 0000000..06ac47a --- /dev/null +++ b/docs/perf/optimization-ranking.md @@ -0,0 +1,283 @@ +# Optimization Ranking + +This file is the current Phase 6 ranking for `aria2-rust-pro` Take 2. + +It is intentionally evidence-first. Each item below exists because current +benchmarks, size reports, or runtime traces show a real remaining cost or a +real verification gap. + +## Current Ranking + +### 1. Shared-runtime live HTTP fixed cost + +Why it ranks first: + +- this is the hottest still-open product path inside the current Phase 6 work +- it already has multiple real wins landed in Take 2 +- the latest product-side scheduler change removed the previous loose-cap/cache + pressure cliff, so remaining work is now narrower fixed-cost work rather than + broad fanout underutilization +- it is still the clearest place where a product-side win can move the current + benchmark surface + +Current evidence: + +- after the latest scheduler and writeback pass, focused Criterion runs now + land at: + - `loose_cap`: `23.429 .. 23.998 ms` + - `tight_cap`: `23.592 .. 24.049 ms` + - `tight_cap_6way`: `26.089 .. 26.510 ms` + - `cache_pressure_6way_256k`: `20.255 .. 21.380 ms` +- after the later CLI config-projection cleanup, exact reruns kept the same + `tight_cap` band and pushed the current shared-runtime `6way` absolute band + materially lower: + - exact `tight_cap`: `23.224 .. 23.603 ms` + - exact `tight_cap_6way`: `13.422 .. 13.824 ms` +- after the latest small-segment probe and admitted-download segment-budget + pass, the current focused shared-runtime band moved again: + - `loose_cap`: `12.567 .. 13.399 ms` + - `tight_cap`: `12.674 .. 13.071 ms` + - `tight_cap_6way`: `13.773 .. 14.212 ms` + - `cache_pressure_6way_256k`: `7.793 .. 8.019 ms` +- the later broader short bench-surface refresh kept that band and improved it + slightly: + - `loose_cap`: `11.696 .. 11.935 ms` + - `tight_cap`: `11.732 .. 11.957 ms` + - `tight_cap_6way`: `12.649 .. 12.855 ms` + - `cache_pressure_6way_256k`: `7.137 .. 7.452 ms` +- Criterion reports significant improvement in all four shared-runtime lanes: + - `loose_cap`: roughly `-7.2% .. -3.4%` wall time + - `tight_cap`: roughly `-4.4% .. -1.6%` wall time + - `tight_cap_6way`: roughly `-31.9% .. -29.3%` wall time + - `cache_pressure_6way_256k`: roughly `-14.9% .. -9.5%` wall time +- the new result is a product-path Phase 6 win: runtime now recognizes the + upstream-compatible `max-concurrent-downloads` surface, does not throttle + already-registered same-runtime HTTP bootstrap work through the smaller + active-download queue value, uses even worker partitioning instead of + under-filling workers on `6 / 5` style workloads, and collapses segment + dispatcher writeback to the final response per download +- before that fix, the loopback driver imposed a serialized service-side floor + near `96 ms` / `192 ms` because one server worker processed `12` / `24` + range requests one at a time with an `8 ms` synthetic response delay +- direct timing with `ARIA2_RUST_PRO_HTTP_TIMING=1` showed that, in the shared + runtime live HTTP path, the dominant remaining fixed cost was in + request/connection/send rather than dispatcher writeback +- the same timing probe also confirmed that bootstrap/segment + `record_http_transfer_result` work is currently `0 ms` on this host in the + hot shared-runtime lane, so further wins need to come from request shape or + transport cost rather than from more dispatcher-writeback trimming +- the latest exact `tight_cap_6way` timing rerun now shows the hot small-file + shared-runtime lane completing all six downloads in the bootstrap request + alone: + - `planned_segments=0` for every download + - `bootstrap_persist_ms=0` + - `bootstrap_record_ms=0` + - per-request `send_ms` is now typically `10 .. 11 ms` on top of the driver + `8 ms` response delay +- that means the previous app-side bookkeeping cliff is gone in this lane; the + remaining cost is now mostly transport-side send/response floor rather than a + large remaining dispatcher or persistence tax +- after the latest writeback and segment-tag cleanup, targeted reruns from the + current tree show the larger segmented/cache-pressure lane is back in the + fast band and the small 6-way lane improves again: + - `tight_cap_6way`: `13.008 .. 13.228 ms` + - `cache_pressure_6way_256k`: `7.819 .. 7.988 ms` +- after the latest reqwest prepared-request contention pass, focused exact + reruns stay in the same fast band while trimming one more product-path fixed + cost in the shared-runtime connector hot path: + - `tight_cap_6way`: mean about `13.117 ms` + - `cache_pressure_6way_256k`: mean about `7.900 ms` +- root cause for that pass: in concurrent shared-runtime small-file bursts, the + private prepared-request cache could become mostly contention cost because + many one-shot URI shapes fought over the same mutex before any real reuse + existed +- the current connector now treats prepared-request caching as opportunistic: + repeated shapes still reuse cached URL/header preparation when the lock is + available, but contended callers fall back to local uncached preparation + instead of blocking on the cache mutex +- this pass specifically addresses the broader segmented workload instead of + spending more time on the already near-floor no-follow-up small-file path + +Already-landed wins in this lane: + +- shared default reqwest client reuse for no-proxy traffic +- initial probe widened from `bytes=0-0` to the first real segment span +- tiny post-probe tails that would previously fan out into three one-alignment + follow-up requests are now coalesced into two larger follow-up requests +- small-alignment segmented transfers now let the initial range probe cover two + complete `max-connection-per-server` windows, reducing real follow-up range + request count for small live HTTP transfers +- shared-runtime segment fanout now budgets against already-admitted downloads + rather than reusing the smaller active-download queue cap after admission +- the live reqwest connector now also raises its same-host idle connection + budget, keeps idle range-transfer connections warm for short bursts, and + enables TCP keepalive; the broader bench-surface rerun kept live HTTP lanes + fast after this connector change +- `max-concurrent-downloads` is now part of the compat option registry and + runtime projection, with an upstream-compatible default of `5` +- small static HTTP work sets are partitioned evenly across available workers + instead of using contiguous chunks that can under-fill the thread set +- shared-runtime follow-up records now update dispatcher state once per + download from the final segment response, instead of once per range response +- direct URI dispatcher registration +- incremental verified-prefix writeback +- shared-runtime follow-up dispatch through one connection-budgeted segment + batch +- no-clone first-attempt retry fast path +- concurrent loopback segment-server handling in the Criterion suite, which + prevents synthetic service-side serialization from hiding client/runtime + concurrency +- shared-runtime follow-up segments now execute through one batched fanout based + on active downloads and `max-connection-per-server`, instead of spawning + nested per-download segment workers during bootstrap preparation +- live streamed response sinks no longer perform an explicit file flush after + every `reqwest::blocking::Response::copy_to`; write/copy errors are still + surfaced, while range-heavy live transfers avoid a repeated per-response + writeback tax +- shared-runtime segment batches now carry the download index as the tag and + store each segment target path once per download, removing one `PathBuf` clone + per planned segment and reducing batch-result allocation churn +- reqwest prepared live-request caching now falls back to local request + preparation when the shared cache lock is contended, which keeps repeated + range-request reuse available without forcing one-shot concurrent request + bursts to wait on the cache mutex + +Explicitly not counted as a measured shared-runtime win yet: + +- proxy-specific reqwest clients are cached by proxy config, but there is still + no dedicated proxy-path benchmark proving its standalone impact; keep it as + sensible hot-path cleanup rather than counting it as ranked measured evidence + +What still looks promising: + +- lower per-request request/connection/send overhead +- more disciplined validation of where blocking reqwest still pays fixed cost +- only product-path reductions, not benchmark-only cosmetics + +What has already been tried and rejected: + +- forcing shared-runtime follow-up parallelism to honor the global overall cap + regressed the lane by about `9% .. 10%` +- swapping the local worker batches to rayon did not produce a meaningful win +- leaking prepared-request state into the public `HttpRequestModel` was rejected; + the kept version confines reqwest-specific URL/header preparation cache state + to `ReqwestHttpConnector` + +### 2. Binary size pressure from the RPC + HTTP/TLS stack + +Why it ranks second: + +- size evidence is now in-tree and clearly points at one dominant cluster +- the current binary is already smaller than both the checked C++ Pro Core and + the upstream Windows binary, so this is no longer emergency work +- there is still a clear future size lane, but it is less urgent than the live + shared-runtime HTTP hot path + +Current evidence: + +- current Windows Rust binary: `4.94 MiB` (`5,184,512` bytes) +- current checked upstream Windows aria2 binary: `5.39 MiB` +- current checked C++ Pro Core binary: `10.88 MiB` +- top `cargo bloat --crates` buckets are: + - `aria2_rust_pro_rpc` + - `reqwest` + - `rustls` + - `std` + - `aria2_rust_pro_protocol` + +Interpretation: + +- deeper size work should focus on the combined RPC + HTTP/TLS surface +- one recent owned-code win already landed inside the CLI bucket by replacing + repeated config `BTreeMap` materialization with direct + last-wins directive lookup, which notably shrank + `derive_http_session`, `derive_runtime_config`, and + `build_ftp_transfer_parts` +- current CLI-side LLVM IR evidence is refreshed at `53,272` total lines across + `307` function copies, down from the older `59,204` / `320` snapshot +- this is now a ranked future optimization lane, not a closure blocker by + itself + +### 3. RPC pressure guard depth + +Why it ranks third: + +- current synthetic RPC responsiveness evidence now covers both a deterministic + integration guard and Criterion pressure anchors +- this remains a ranked lane because the workload is still synthetic and should + not be overclaimed as a public-swarm certification +- it is no longer an immediate Phase 6 evidence blocker + +Current evidence: + +- the current pressure guards: + - repeated `tellStatus` + - sampled `getFiles` + - repeated `tellActive` + - repeated `tellGlobalStat` + - BT-like synthetic runtime churn +- the denser mixed guard now covers 96 BT-like magnet tasks over 6 rounds, + including 576 `tellStatus` calls and 72 sampled `getFiles` calls under + per-download runtime tick churn +- current Criterion anchors include: + - `rpc_mixed_pressure/mixed_rpc/96`: `92.383 us` + - `rpc_mixed_pressure/mixed_rpc/192`: `175.169 us` + - `bt_visibility_pressure/bt_visibility/32`: `2.3293 ms` + - `bt_visibility_pressure/bt_visibility/64`: `2.1302 ms` +- the current guard is documented in `rpc-pressure-evidence.md` +- that document explicitly says the thresholds are broad and not a final + performance certification + +Needed future expansion: + +- higher scheduler pressure +- clearer resource counters suitable for long-term perf reporting + +### 4. Same-host comparison noise and interpretation discipline + +Why it still matters: + +- the local comparison report is now useful and much less misleading than it + used to be +- but it is still a whole-process host-local comparison, not a precision + microbenchmark + +Current evidence: + +- `local-comparison.md` now reports medians and spread after warmup +- the driver no longer suffers from coarse accept/sleep quantization +- the Rust rows now complete with matching payload size and SHA256 + +Interpretation: + +- this report is good enough to act as a local regression anchor +- it should not be overclaimed as precise proof for micro-optimizations + +## Current Phase 6 Read + +What is already true: + +- benchmark evidence exists +- size/bloat evidence exists +- multiple real product-side wins are already landed and documented +- the current hottest open lane is identified and ranked +- non-HTTP RPC and BT-visibility pressure now have current deterministic and + Criterion evidence +- the Phase 6 shared-runtime loose-cap/cache cliff is fixed by product code, + not by benchmark-only masking +- current broader bench, bloat, binary-size, and LLVM-lines evidence are now + synchronized with the latest Phase 6 tree + +What is not yet honest to claim: + +- that the shared-runtime live HTTP lane is fully exhausted +- that size work is fully exhausted +- that the current RPC pressure guard is a public-network performance + certification + +## References + +- `progress.md` +- `docs\perf\local-comparison.md` +- `docs\perf\rpc-pressure-evidence.md` +- `docs\perf\size-evidence.md` diff --git a/docs/perf/rpc-pressure-evidence.md b/docs/perf/rpc-pressure-evidence.md new file mode 100644 index 0000000..5ac9d52 --- /dev/null +++ b/docs/perf/rpc-pressure-evidence.md @@ -0,0 +1,82 @@ +# RPC Pressure Evidence + +This file records the current synthetic RPC responsiveness guard for +`aria2-rust-pro`. + +## Scope + +Current guard coverage is synthetic but no longer limited to a single +`tellStatus` loop: + +- in-process dispatcher +- BT-like magnet workload +- repeated `aria2.tellStatus` +- sampled `aria2.getFiles` +- repeated `aria2.tellActive` +- repeated `aria2.tellGlobalStat` +- Criterion `rpc_mixed_pressure` and `bt_visibility_pressure` anchors + +This is not a public-swarm benchmark. It is a deterministic regression guard +for scheduler/RPC responsiveness while the runtime is still approximation-heavy. + +## Guard Tests + +- `rpc_bt_pressure_guard_keeps_status_active_and_global_stat_responsive` +- `rpc_bt_mixed_pressure_guard_covers_churned_status_files_and_global_views` + +Basic guard shape: + +- 64 BT-like magnet tasks +- 4 pressure rounds +- 256 total `tellStatus` calls +- 4 `tellActive` calls +- 4 `tellGlobalStat` calls +- runtime tick updates injected during the loop to keep non-zero BT-like state + +Mixed guard shape: + +- 96 BT-like magnet tasks +- 6 pressure rounds +- 576 total `tellStatus` calls +- 72 sampled `getFiles` calls +- 6 `tellActive` calls +- 6 `tellGlobalStat` calls +- per-download runtime tick churn before every status probe + +## Current Thresholds + +These thresholds are deliberately broad. They are there to catch obvious +regressions, not to certify final performance: + +- cumulative `tellStatus` time across the run: `<= 2000ms` +- cumulative `tellActive` time across the run: `<= 500ms` +- cumulative `tellGlobalStat` time across the run: `<= 500ms` +- cumulative mixed guard time across the run: `<= 4000ms` + +## Current Criterion Anchors + +Current focused Criterion runs on this host: + +| Benchmark | Mean | 95% CI | +| --- | ---: | ---: | +| `rpc_mixed_pressure/mixed_rpc/96` | `92.383 us` | `90.867 .. 93.895 us` | +| `rpc_mixed_pressure/mixed_rpc/192` | `175.169 us` | `160.464 .. 192.884 us` | +| `bt_visibility_pressure/bt_visibility/32` | `2.3293 ms` | `1.0756 .. 4.0986 ms` | +| `bt_visibility_pressure/bt_visibility/64` | `2.1302 ms` | `2.0707 .. 2.2057 ms` | + +Interpretation: + +- the deterministic integration tests now cover mixed BT/RPC status, file, and + global-stat surfaces under runtime churn +- the Criterion anchors give a current non-HTTP performance regression guard +- the thresholds remain intentionally broad so host-local noise does not turn a + synthetic guard into a flaky gate + +## Next Steps + +Further work should extend this guard with: + +- explicit scheduler tick pressure +- resource counters suitable for later perf reporting +- a documented comparison against the C++ reference once comparable benchmark + suites exist diff --git a/docs/perf/size-evidence.md b/docs/perf/size-evidence.md new file mode 100644 index 0000000..9afd135 --- /dev/null +++ b/docs/perf/size-evidence.md @@ -0,0 +1,137 @@ +# Size Evidence + +This file records the current binary-size and code-size evidence for +`aria2-rust-pro` in Take 2 of the modernization goal. + +## Commands + +```powershell +rtk cargo build --release -p aria2-rust-pro-cli --manifest-path .\Cargo.toml -j $env:NUMBER_OF_PROCESSORS +rtk cargo bloat --release -p aria2-rust-pro-cli --bin aria2-rust-pro --crates -n 40 -j $env:NUMBER_OF_PROCESSORS +rtk cargo rustc --release -p aria2-rust-pro-cli --bin aria2-rust-pro --manifest-path .\Cargo.toml -- --emit=llvm-ir +rtk cargo llvm-lines --files target\release\deps\aria2_rust_pro.aria2_rust_pro_cli-d4c445ae209bfe38.aria2_rust_pro_cli.b493e489391677ca-cgu.0.rcgu.o.rcgu.ll +``` + +## Binary Size Snapshot + +Measured on the current Windows host: + +| Binary | Bytes | MiB | +| --- | ---: | ---: | +| `aria2-rust-pro.exe` | `5,213,184` | `4.97` | +| current C++ Pro Core `aria2c.exe` | `11,412,480` | `10.88` | +| upstream Windows `aria2c.exe` | `5,649,408` | `5.39` | + +Observations: + +- the current Rust binary is about `54.3%` smaller than the current C++ Pro Core + artifact on this host +- the current Rust binary remains about `7.7%` smaller than the upstream + Windows aria2 binary as well +- the latest reduction came from making the release profile more artifact-shaped: + `codegen-units = 1`, `lto = "thin"`, and `strip = "symbols"` +- a later Phase 6 config-projection cleanup removed another small tranche by + replacing repeated release-path `BTreeMap` materialization + with last-wins directive lookup on demand +- the latest checked tree, after the recent structural scheduler/DHT cleanup, + leaves the release artifact at `5,213,184` bytes, up `28,672` bytes from the + previous recorded snapshot rather than down +- the host artifact still stays below both checked C++ references + +## Cargo Bloat Summary + +Current top crate contributions from `cargo bloat --crates`: + +| Crate | `.text` size | +| --- | ---: | +| `std` | `815.4 KiB` | +| `aria2_rust_pro_rpc` | `488.4 KiB` | +| `rustls` | `421.7 KiB` | +| `aria2_rust_pro_cli` | `246.7 KiB` | +| `aria2_rust_pro_protocol` | `236.3 KiB` | +| `reqwest` | `213.9 KiB` | +| `ring` | `124.2 KiB` | +| `hyper_util` | `107.8 KiB` | +| `hyper` | `104.2 KiB` | +| `aria2_rust_pro_core` | `100.6 KiB` | +| `tokio` | `94.4 KiB` | + +Headline totals from the same run: + +- `.text` section: about `3.6 MiB` +- file size from the bloat run: about `5.0 MiB` + +Interpretation: + +- the biggest owned size buckets are `aria2_rust_pro_rpc`, + `aria2_rust_pro_protocol`, `aria2_rust_pro_cli`, and `aria2_rust_pro_core` +- the biggest third-party buckets are the HTTP/TLS stack: + `reqwest`, `rustls`, `hyper`, `hyper_util`, `tokio`, and `ring` +- future size work should therefore focus on: + - whether the default host binary really needs the full RPC/TLS surface in one + always-on artifact + - whether reqwest / rustls features can be trimmed without breaking the + compatibility contract + - whether any always-linked RPC/HTTP helpers can be split or made less eager + - whether a future release flow should emit a stripped end-user artifact plus + a separate debug-symbol artifact rather than making local release builds do + both jobs at once +- within the owned CLI bucket, the same config-projection cleanup previously cut several + previously ranked helpers materially in the filtered `cargo bloat` view: + - `run_from_env`: `31.2 KiB` -> `30.2 KiB` + - `projection::derive_http_session`: `8.6 KiB` -> `5.7 KiB` + - `projection::derive_runtime_config`: `8.3 KiB` -> `4.0 KiB` + - `build_ftp_transfer_parts`: `6.4 KiB` -> `3.9 KiB` + +## LLVM Lines Snapshot + +Direct `cargo llvm-lines --release -p aria2-rust-pro-cli --bin aria2-rust-pro` +currently fails on this Windows/MSVC host while linking the temporary +`cargo-llvm-lines` crate, with many unresolved external symbols. The project +itself still builds in release mode; the failure is limited to that tool's +temporary relink path. + +The usable current workaround is: + +1. emit release LLVM IR with `cargo rustc -- --emit=llvm-ir` +2. point `cargo llvm-lines --files` at the generated CLI `.ll` file + +That current CLI crate IR snapshot reports: + +- total: `53,272` LLVM IR lines across `307` function copies +- top entries: + - `runtime_host::execute_run_invocation`: `5,538` lines + - `run_from_env`: `4,380` lines + - `parallel_http_runtime::execute_parallel_http_entries`: `3,751` lines + - `http_runtime::execute_segment_transfers`: `2,364` lines + - `http_runtime::execute_tagged_segment_transfers_with_parallelism`: `2,345` + lines + - `runtime_summary::collect_runtime_execution_summary`: `1,207` lines + +Interpretation: + +- the largest CLI-side IR buckets now line up with the Phase 6 runtime and HTTP + transfer paths rather than a random unrelated module +- `parallel_http_runtime` remains a legitimate future split/size target, but it + is also the path that just delivered the latest shared-runtime performance win +- full-workspace release LLVM-lines still needs a non-MSVC or fixed + `cargo-llvm-lines` path before it should be treated as a strict gate + +## Take 2 Reading + +For Take 2, the honest conclusion is: + +- size evidence now exists in-tree +- one real size-reduction tranche is now landed through the release profile +- the Rust host binary is now smaller than both the current C++ Pro Core and + the upstream Windows binary checked on this host +- the main size pressure comes from the combined RPC + HTTP/TLS stack, not from + one surprising internal crate explosion +- CLI-side LLVM IR now points at runtime execution and parallel HTTP scheduling + as the next owned code-size targets +- the latest CLI IR refresh still confirms the earlier config-projection and + shared-runtime cleanup reduced total CLI IR from `59,204` to `53,272` lines, + but the newest structural tree should now be treated as synchronized mainly + through the refreshed release build and `cargo bloat --crates` snapshot above +- deeper size reduction remains a future optimization lane, but the Phase 6 + evidence set is now current enough to rank that work honestly diff --git a/docs/release/README.md b/docs/release/README.md new file mode 100644 index 0000000..ffb83a4 --- /dev/null +++ b/docs/release/README.md @@ -0,0 +1,305 @@ +# Release Packaging and Local Deployment + +This document covers the current local release workflow for `aria2-rust-pro`. +The scripts under `scripts/release/` cover three concrete needs: + +- host-runnable `--version` smoke validation; +- deterministic local artifact naming plus SHA-256 generation; +- packaging of the current workspace's Windows or Linux release binary into a + ready-to-share archive. +- exporting a locally built Docker image tag into a portable tar archive under +`dist\docker\`. + +As of the Phase 3 Cargo-native workflow push, the primary entrypoints now live +in `xtask/`. The PowerShell files under `scripts/release/` remain as thin +compatibility shims so existing local habits still work, but the canonical +project automation surface is now: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke +``` + +These commands do not update `progress.md` or the compatibility ledger, and +they do not claim cross-host release certification by themselves. They are +local release helpers for the current workspace state. + +## Files + +- `xtask/`: canonical Cargo-native workflow entrypoints for release smoke and + local packaging. +- `scripts/release/smoke-version.ps1`: thin shim around + `xtask release smoke-version`. +- `scripts/release/package-local.ps1`: thin shim around + `xtask release package-local`. +- `scripts/docker/smoke-local.ps1`: thin shim around + `xtask docker smoke-local`. +- `scripts/docker/smoke.ps1`: thin shim around `xtask docker smoke`. +- `scripts/docker/export-local.ps1`: thin shim around + `xtask docker export-local`. +- `docs/release/RELEASE-NOTES-TEMPLATE.md`: reusable release-page and handoff + template for public-facing releases. + +## Artifact naming + +The package script emits archives named like: + +- `aria2-rust-pro-v1.0.0-x86_64-pc-windows-msvc.zip` +- `aria2-rust-pro-v1.0.0-x86_64-unknown-linux-gnu.tar.gz` + +The target triple always stays in the file name so mixed-host staging stays +unambiguous. + +Output defaults to the workspace-level dist area: + +```powershell +dist\release\v\ +``` + +Generated files per package run: + +- the archive itself (`.zip` on Windows targets, `.tar.gz` otherwise); +- `.sha256`; +- `SHA256SUMS.txt`; +- `.manifest.json`. + +## Version smoke + +From the repo root: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version +pwsh ./scripts/release/smoke-version.ps1 +``` + +Build first, then smoke: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version --build +pwsh ./scripts/release/smoke-version.ps1 -Build +``` + +Smoke a specific binary: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version --binary-path .\target\release\aria2-rust-pro.exe +pwsh ./scripts/release/smoke-version.ps1 -BinaryPath .\target\release\aria2-rust-pro.exe +``` + +The command succeeds only when the rendered banner keeps the upstream-compatible +first line `aria2 version 1.37.0` and also contains +`Rust rewrite package: aria2-rust-pro `. + +## Versioning and changelog + +The workspace version in the root `Cargo.toml` is the source of truth. Release +tags must use `vMAJOR.MINOR.PATCH` and match that version. Preview the next +user-facing changelog entry with: + +```powershell +rtk git-cliff --config .\cliff.toml --unreleased +``` + +For a release tag, a checked-in `docs/release/vX.Y.Z.md` remains authoritative. +When it is absent, the release workflow generates the Gitea release body from +`cliff.toml` and the Conventional Commit history. + +On an actual release tag, the release workflow compares each public workspace +crate against the preceding SemVer tag with `cargo-semver-checks`. The first +release tag has no predecessor and therefore skips that comparison. + +## Packaging the current host build + +Build and package the host release binary: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build +pwsh ./scripts/release/package-local.ps1 -Build +``` + +Package an already-built host binary without rebuilding: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local +pwsh ./scripts/release/package-local.ps1 +``` + +Write artifacts to a custom directory: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build --output-root .\dist\release +pwsh ./scripts/release/package-local.ps1 -Build -OutputRoot .\dist\release +``` + +## Packaging Windows and Linux artifacts from the current workspace + +### Windows host, Windows artifact + +On the current Windows workspace, the default path uses: + +- `target\release\aria2-rust-pro.exe` +- optional `target\release\aria2_rust_pro.pdb` + +The archive is a `.zip` containing the binary, optional PDB, root `README.md`, +and the release packaging guide under `docs/release/README.md`. Version-specific +release notes such as `docs/release/v1.0.0.md` are used as Gitea release body +text, not bundled into every archive by default. + +### Linux host, Linux artifact + +On Linux with PowerShell 7 and Cargo available, the same script works: + +```bash +pwsh ./scripts/release/package-local.ps1 -Build +``` + +The Linux archive format is `.tar.gz`, and the default packaged binary path is +`target/release/aria2-rust-pro`. + +### Cross-target handling from one workspace + +When the binary already exists under a target-specific Cargo directory, pass the +target triple: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build --target-triple x86_64-pc-windows-msvc +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build --target-triple x86_64-unknown-linux-gnu +pwsh ./scripts/release/package-local.ps1 -Build -TargetTriple x86_64-pc-windows-msvc +pwsh ./scripts/release/package-local.ps1 -Build -TargetTriple x86_64-unknown-linux-gnu +``` + +If the workspace cannot execute the packaged binary on the current host, package +the prebuilt file directly and skip the runtime smoke: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --target-triple x86_64-unknown-linux-gnu --source-binary .\target\x86_64-unknown-linux-gnu\release\aria2-rust-pro --skip-version-smoke +pwsh ./scripts/release/package-local.ps1 ` + -TargetTriple x86_64-unknown-linux-gnu ` + -SourceBinary .\target\x86_64-unknown-linux-gnu\release\aria2-rust-pro ` + -SkipVersionSmoke +``` + +That flow supports current-workspace staging for non-host artifacts, but the +runtime smoke must still be re-run on a compatible machine before calling the +artifact release-ready. + +## Local deployment checklist + +1. Unpack the archive to a clean directory. +2. Run the binary with `--version` and confirm the expected workspace version. +3. Compare the archive checksum with either `.sha256` or + `SHA256SUMS.txt`. +4. For container deployment, keep using the documented `docker/` workflow plus + `xtask docker smoke`, `xtask docker smoke-local`, or + `xtask docker export-local`. The matching `scripts/docker/*.ps1` files are + compatibility wrappers for those Cargo-native commands. +5. For direct CLI deployment, provide the same config/session/runtime paths you + already use for local compatibility testing. + +## Exporting a local Docker image tar + +When the Docker daemon is available and a local image exists or can be built: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker export-local --build +pwsh ./scripts/docker/export-local.ps1 -Build +``` + +That command writes a tar archive, checksum files, and a manifest under: + +```powershell +dist\docker\v\ +``` + +The default tag is `aria2-rust-pro:local`. To export another tag: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker export-local --tag aria2-rust-pro:smoke +pwsh ./scripts/docker/export-local.ps1 -Tag aria2-rust-pro:smoke +``` + +## Docker smoke checks + +After packaging or deployment changes, run the Cargo-native Docker smokes from +the repo root: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke --tag aria2-rust-pro:smoke +``` + +`xtask docker smoke-local` proves the entrypoint/config-generation path without +a Docker daemon. `xtask docker smoke` builds the image, starts a container, +checks generated config, and probes live JSON-RPC inside the running container. + +The PowerShell scripts are compatibility wrappers around the same commands: + +```powershell +pwsh ./scripts/docker/smoke-local.ps1 +pwsh ./scripts/docker/smoke.ps1 -Tag aria2-rust-pro:smoke +``` + +The daemon-backed wrapper also forwards `-Build`, `-HostRpcPort`, +`-RpcSecret`, and `-SpecialMode` to the matching `xtask docker smoke` options. + +## Self-hosted Gitea release verification + +The self-hosted Gitea release workflow should be validated before cleaning up +old runner records in the Gitea UI. Keep runner cleanup as an infrastructure +follow-up, not part of the first release recovery step. + +Use this order after CI has gone green on `main`: + +1. Trigger the release workflow with `workflow_dispatch` and leave + `validation_only=true`, or run the equivalent release commands on the runner + host. Validation mode runs the release gates from the current ref and skips + Gitea publishing, so it can test the runner without mutating an existing + release. +2. Confirm the strict gate reaches the daemon-backed Docker smoke and that + `xtask docker smoke` completes its JSON-RPC probe. +3. Confirm `xtask docker export-local --build --tag aria2-rust-pro:release` + stages the Docker tar, manifest, and `SHA256SUMS.txt`. +4. For an actual tag-publish run, set `validation_only=false` or push a release + tag, then confirm `scripts/ci/publish-gitea-release.sh` can create or update + the Gitea release with all staged assets. +5. Only after the release workflow has passed, remove stale offline runner UI + records that no longer correspond to the active runner. + +Do not delete the active runner registration while release validation is still +in progress. The current production runner identity should be confirmed from +the latest passing Gitea Actions run before any old offline entry is removed. +Do not move an already published tag just to validate the workflow; use the +default no-publish validation mode instead. + +Recovered runner evidence: + +- release validation run 107 passed on commit `6d4956b` with + `validation_only=true`; +- push CI run 108 passed on the same commit; +- the release validation spent roughly 720 seconds in fast gates and + 784 seconds in strict gates, while checkout, tool bootstrap, release package, + and Docker export were short; +- no stale `GITEA-ACTIONS` containers or persisted temporary Gitea tokens should + remain after runner-side validation. + +Treat those timings as a baseline when optimizing the self-hosted runner. Prefer +persistent Cargo registry/git cache or further runner-image prebaking before +target-directory caching, because target caches can be polluted by toolchain, +feature, and commit differences. + +## Scope and remaining release limits + +These scripts are the active local release helpers: + +- they stage host artifacts and Docker image tar exports from the current + workspace; +- they do not, by themselves, certify that the current tree has reclosed every + reopened modernization phase; +- archived release records are preserved only as historical evidence under + `docs/archive/`; +- Docker image publication/signing still remains an external release-management + concern, while `export-local.ps1` and `xtask docker export-local` stage a + portable tar from the local daemon state under + `dist\docker\v\`. diff --git a/docs/release/RELEASE-NOTES-TEMPLATE.md b/docs/release/RELEASE-NOTES-TEMPLATE.md new file mode 100644 index 0000000..91dd08b --- /dev/null +++ b/docs/release/RELEASE-NOTES-TEMPLATE.md @@ -0,0 +1,69 @@ +# aria2-rust-pro Release Notes Template + +Use this template when cutting a public-facing release, local handoff package, +or Gitea release page. + +--- + +## Summary + +One short paragraph that answers: + +- what this release is +- who should care +- whether this is mainly a feature release, compatibility release, packaging + release, or bugfix release + +## Highlights + +- highlight 1 +- highlight 2 +- highlight 3 + +## Compatibility Notes + +- upstream aria2 baseline: +- Aria2-Pro-Core behavior notes: +- Docker compatibility notes: +- RPC compatibility notes: + +## Packaging + +Artifacts included in this release: + +- Windows: +- Linux: +- Docker image tar or image tag: + +Checksums: + +- `SHA256SUMS.txt` included: yes or no +- manifest files included: yes or no + +## Migration Notes + +Operator actions required before upgrade: + +- config changes: +- compose or Docker env changes: +- host deployment changes: +- rollback considerations: + +## Verification + +Commands or evidence used to validate the release: + +```text +list the exact commands run +``` + +## Known Limits + +- limit 1 +- limit 2 + +## Full Change List + +- item +- item +- item diff --git a/docs/release/v1.0.0.md b/docs/release/v1.0.0.md new file mode 100644 index 0000000..0b2a8a8 --- /dev/null +++ b/docs/release/v1.0.0.md @@ -0,0 +1,53 @@ +# aria2-rust-pro 1.0.0 + +`aria2-rust-pro 1.0.0` is the first public release published from the +self-hosted Gitea repository and its built-in Actions pipeline. + +This release establishes the first automated release lane for the project: + +- Windows host packaging through the Cargo-native `xtask` release flow +- portable Docker image tar export through the Cargo-native Docker flow +- release manifests and SHA-256 checksum files attached beside the primary + artifacts + +## Highlights + +- Rust 2024 rewrite of the current Aria2-Pro-Core line +- Cargo-native release packaging and Docker export automation +- self-hosted Gitea Actions runner and release workflow for future releases + +## Compatibility Notes + +- version smoke preserves the upstream-compatible first line + `aria2 version 1.37.0` +- Docker images keep the `aria2c` entrypoint shape by symlinking + `aria2c -> aria2-rust-pro` +- migration and deployment guides remain in-repo under `docs/migration/` and + `docs/deployment/` + +## Artifacts + +This automated release is expected to publish: + +- one Windows MSVC release archive under `dist/release/v1.0.0/` +- one Docker image tar under `dist/docker/v1.0.0/` +- JSON manifest files for both artifact families +- per-file SHA-256 checksums and `SHA256SUMS.txt` + +## Verification + +The release workflow performs: + +- workspace fast gates +- nightly `cargo udeps` +- `cargo deny` +- release version smoke +- daemon-free Docker smoke +- daemon-backed Docker smoke +- local release packaging +- Docker image export + +## Known Limits + +- the first release asset set is Windows-first plus Docker image tar +- broader multi-platform release archives remain future work diff --git a/docs/testing/quality-gates.md b/docs/testing/quality-gates.md new file mode 100644 index 0000000..e1217b4 --- /dev/null +++ b/docs/testing/quality-gates.md @@ -0,0 +1,147 @@ +# Quality Gates + +Primary test runner: + +```powershell +rtk cargo nextest run --workspace --all-targets --all-features +``` + +Required final gates: + +```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 +rtk cargo +nightly udeps --workspace --all-targets --all-features --locked +rtk cargo deny --locked check +``` + +On this Windows host, `cargo deny` may inherit a broken Git `schannel` HTTPS +path and fail with `SEC_E_NO_CREDENTIALS`. The repository's strict sweep now +forces Git's `http.sslbackend=openssl` for the deny lane so the final gate does +not depend on ambient machine Git TLS settings. + +If an `rtk` equivalent is unavailable or broken for a command, record the +fallback command in `progress.md`. + +Recommended one-shot local sweep: + +```powershell +pwsh ./scripts/testing/strict-sweep.ps1 +``` + +## Self-Hosted Gitea Runner Notes + +The self-hosted Gitea Actions runner image used for this repository must keep +`CARGO_BUILD_JOBS=2` for CI and release jobs. Do not lower it to `1` as a +generic resource-throttling tweak. + +The fast gate runs `cargo clippy` across the workspace with strict lint levels. +On the NAS runner image, `CARGO_BUILD_JOBS=1` caused the clippy lane to stall in +a nested Cargo metadata / jobserver / target-lock chain. Keeping two build jobs +gives the nested Cargo process enough scheduling room while still bounding +runner load. + +Do not add `-D clippy::cargo` back to CI or the local strict sweep without a +fresh runner-side validation pass. That lint group spawns nested +`cargo metadata` from clippy-driver, which can wait behind the parent +cargo/clippy target lock on this NAS runner. Dependency, license, and advisory +policy remains covered by the strict gate's `cargo deny` and `cargo udeps` +lanes. + +The runner image should keep `cargo-nextest`, `cargo-deny`, and `cargo-udeps` +preinstalled. Baking those tools into the image reduced the `Install Cargo +tools` step from roughly 26 minutes to 0-1 seconds on the self-hosted runner. +Future image updates should preserve that tool cache before looking for more +invasive target-directory caching. + +`docker/entrypoint.sh` must retain executable mode (`100755`) in Git. Linux +runner smokes execute it directly, and a non-executable checkout fails with +`Permission denied` before the entrypoint behavior is tested. + +`xtask docker` defaults `DOCKER_BUILDKIT=0` when the caller has not set a +builder preference. Synology Docker 24 left buildx sessions idle on the full +release Dockerfile, while the classic builder completed the release image build +and export path. Override `DOCKER_BUILDKIT` only after testing the runner's +Docker daemon behavior. + +Current runner timing evidence shows the remaining long steps are the Rust +quality gates themselves: cold registry/download work, Rust compilation, +nightly `build-std`, and NAS small-file IO. Docker image tar export was measured +around 20 seconds and was not the bottleneck in the recovered release run. + +Keep the value aligned in both places: + +- the runner image or image Dockerfile, such as + `/volume1/docker/gitea/runner-images/aria2-rust-1.88/Dockerfile`; +- `.gitea/workflows/ci.yml` and `.gitea/workflows/release.yml`. + +If a future CI run appears stuck rather than failing with a compiler or test +error, check this before changing source code: + +```bash +docker logs --tail 300 gitea-runner +docker inspect gitea-runner --format '{{json .Config.Env}}' | jq . +``` + +## CLI/tests/docs slice fallback lane + +When lower-layer workspace crates are still red on strict docs or pedantic +lint, the CLI/tests/docs owner should still keep the owned surface ready for +final closeout with slice-scoped verification against the alternate target +directory: + +```powershell +$env:CARGO_TARGET_DIR='.\target-alt' +rtk cargo check -p aria2-rust-pro-cli -p aria2-rust-pro-tests --all-targets +rtk cargo test -p aria2-rust-pro-cli +rtk cargo test -p aria2-rust-pro-tests +rtk cargo bench -p aria2-rust-pro-tests --bench rpc_pressure --no-run +rtk rustfmt --check crates/aria2-rust-pro-cli/src/lib.rs crates/aria2-rust-pro-cli/src/main.rs crates/aria2-rust-pro-tests/src/lib.rs crates/aria2-rust-pro-tests/benches/rpc_pressure.rs +rtk cargo clippy -p aria2-rust-pro-cli -p aria2-rust-pro-tests --all-targets --no-deps -- -D warnings -D clippy::pedantic +``` + +Notes for this lane: + +- `cargo clippy --no-deps` is still useful for owned-file cleanup, but if + workspace-path dependencies fail before the CLI/tests targets are linted, + record the exact blocker in `progress.md` instead of widening the edit scope. +- `rustfmt` is file-scoped here on purpose so the lane can avoid touching + unrelated crates while another worker is active elsewhere in the workspace. + +## Compatibility Evidence Slice + +Lane F extends the integration suite in +`crates/aria2-rust-pro-tests/src/lib.rs` with representative RPC parity checks +that do not modify dispatcher or transport implementation files. + +Recommended focused pass while iterating on RPC compatibility: + +```powershell +rtk cargo test -p aria2-rust-pro-tests parsed_jsonrpc_change_option_request_shape_matches_manual_dispatch_state +rtk cargo test -p aria2-rust-pro-tests xmlrpc_and_jsonrpc +``` + +Representative coverage kept for compatibility relock work: + +- Raw JSON-RPC request parsing drives the same downstream `aria2.changeOption` + state as a manually constructed request object. +- `aria2.getGlobalOption` returns the same object payload through JSON-RPC and + XML-RPC after option normalization. +- Filtered BitTorrent `aria2.tellStatus` responses keep the same request-shape + semantics and runtime field values across JSON-RPC and XML-RPC. +- `system.multicall` preserves nested result structure consistently across both + RPC front doors for mixed payloads such as version, session, and BT status. +- Invalid-GID failures keep the same underlying RPC error message across + JSON-RPC and XML-RPC, with XML-RPC still wrapping it in the upstream-style + fault envelope. + +Remaining scope limits for this lane: + +- These tests prove in-process request/response parity, not socket-level server + transport framing. +- XML-RPC number coercion is only covered where aria2-style payloads are + expected to round-trip through the shared `RpcValue` surface. +- Full dispatcher internals, router behavior, and transport implementations are + intentionally left to the lanes that own those files. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..259d097 --- /dev/null +++ b/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":dependencyDashboard"], + "baseBranches": ["main"], + "labels": ["dependencies"], + "prConcurrentLimit": 3, + "prHourlyLimit": 1, + "rebaseWhen": "conflicted", + "packageRules": [ + { + "description": "Automerge patch updates only after CI succeeds", + "matchUpdateTypes": ["patch"], + "automerge": true, + "automergeType": "pr", + "platformAutomerge": false, + "ignoreTests": false + }, + { + "description": "Minor and major updates always require review", + "matchUpdateTypes": ["minor", "major"], + "automerge": false + } + ], + "lockFileMaintenance": { + "enabled": true, + "automerge": false, + "schedule": ["before 4am on the first day of the month"] + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..afe3ce0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..710f8c5 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,7 @@ +edition = "2024" +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/scripts/ci/bootstrap-rust.sh b/scripts/ci/bootstrap-rust.sh new file mode 100755 index 0000000..b928d29 --- /dev/null +++ b/scripts/ci/bootstrap-rust.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +stable_toolchain="${1:-1.88.0}" +nightly_toolchain="${2:-nightly}" + +if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none +fi + +if [ -f "${HOME}/.cargo/env" ]; then + # shellcheck disable=SC1091 + source "${HOME}/.cargo/env" +fi +command -v rustup >/dev/null 2>&1 || { + echo "rustup is required after bootstrap" >&2 + exit 1 +} + +export RUSTUP_MAX_RETRIES="${RUSTUP_MAX_RETRIES:-3}" + +toolchain_is_installed() { + local requested="$1" + local installed + + while read -r installed _; do + if [[ "${installed}" == "${requested}" || "${installed}" == "${requested}-"* ]]; then + return 0 + fi + done < <(rustup toolchain list) + + return 1 +} + +component_is_installed() { + local toolchain="$1" + local component="$2" + + rustup component list --toolchain "${toolchain}" --installed \ + | awk '{ print $1 }' \ + | grep -Fxq "${component}" +} + +ensure_toolchain() { + local toolchain="$1" + shift + local missing_components=() + local install_args=() + local component + + if toolchain_is_installed "${toolchain}"; then + for component in "$@"; do + if ! component_is_installed "${toolchain}" "${component}"; then + missing_components+=("${component}") + fi + done + + if [ "${#missing_components[@]}" -eq 0 ]; then + echo "rustup: reusing installed ${toolchain}" + return 0 + fi + + rustup component add --toolchain "${toolchain}" "${missing_components[@]}" + return 0 + fi + + for component in "$@"; do + install_args+=(--component "${component}") + done + + rustup toolchain install "${toolchain}" --profile minimal "${install_args[@]}" +} + +ensure_toolchain "${stable_toolchain}" rustfmt clippy rust-src +if [ "${nightly_toolchain}" != "none" ]; then + ensure_toolchain "${nightly_toolchain}" rust-src +fi +rustup default "${stable_toolchain}" + +cargo --version +rustc --version +rustup show active-toolchain diff --git a/scripts/ci/check-conventional-commits.sh b/scripts/ci/check-conventional-commits.sh new file mode 100755 index 0000000..a2b024f --- /dev/null +++ b/scripts/ci/check-conventional-commits.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly HEADER_PATTERN='^(feat|fix|perf|refactor|test|docs|build|ci|chore|revert)(\([[:alnum:]./_-]+\))?!?: .+$' + +is_conventional_header() { + [[ "$1" =~ ${HEADER_PATTERN} ]] +} + +run_self_test() { + local subject + + for subject in \ + 'feat(rpc): add request timeout' \ + 'fix!: preserve legacy config behavior' \ + 'docs(release): document versioning'; do + is_conventional_header "${subject}" || { + echo "expected valid Conventional Commit: ${subject}" >&2 + exit 1 + } + done + + for subject in 'update dependency' 'feature: invalid type' 'fix missing separator'; do + if is_conventional_header "${subject}"; then + echo "expected invalid Conventional Commit: ${subject}" >&2 + exit 1 + fi + done +} + +if [ "${1:-}" = "--self-test" ]; then + run_self_test + echo "Conventional Commit validator self-test passed" + exit 0 +fi + +baseline="${1:-v1.0.0}" +git rev-parse --verify "${baseline}^{commit}" >/dev/null + +invalid=0 +while IFS=$'\t' read -r commit subject; do + if ! is_conventional_header "${subject}"; then + echo "${commit}: ${subject}" >&2 + invalid=1 + fi +done < <(git log --format='%H%x09%s' --no-merges "${baseline}..HEAD") + +if [ "${invalid}" -ne 0 ]; then + echo "Conventional Commit validation failed after ${baseline}" >&2 + exit 1 +fi + +echo "Conventional Commit validation passed after ${baseline}" diff --git a/scripts/ci/install-cargo-tools.sh b/scripts/ci/install-cargo-tools.sh new file mode 100755 index 0000000..27de15c --- /dev/null +++ b/scripts/ci/install-cargo-tools.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-fast}" +stable_toolchain="${2:-1.88.0}" +nightly_toolchain="${3:-nightly}" +nextest_version="${NEXTEST_VERSION:-0.9.114}" +cargo_deny_version="${CARGO_DENY_VERSION:-0.19.8}" +cargo_udeps_version="${CARGO_UDEPS_VERSION:-0.1.61}" +git_cliff_version="${GIT_CLIFF_VERSION:-2.13.1}" +cargo_semver_checks_version="${CARGO_SEMVER_CHECKS_VERSION:-0.44.0}" + +if [ -f "${HOME}/.cargo/env" ]; then + # shellcheck disable=SC1091 + source "${HOME}/.cargo/env" +fi +command -v cargo >/dev/null 2>&1 || { + echo "cargo is required to install CI tools" >&2 + exit 1 +} + +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" + +install_nextest() { + local cargo_home + cargo_home="${CARGO_HOME:-${HOME}/.cargo}" + + if cargo nextest --version 2>/dev/null | grep -q " ${nextest_version}"; then + return + fi + + curl -LsSf "https://get.nexte.st/${nextest_version}/linux" \ + | tar zxf - -C "${cargo_home}/bin" +} + +install_with_binary_fallback() { + local toolchain="$1" + local crate="$2" + shift 2 + + if command -v cargo-binstall >/dev/null 2>&1; then + cargo binstall -y "${crate}" "$@" && return + fi + + cargo +"${toolchain}" install --locked --force --jobs "${CARGO_BUILD_JOBS}" "${crate}" "$@" +} + +tool_has_version() { + local command_name="$1" + local expected_version="$2" + + "${command_name}" --version 2>/dev/null | grep -q " ${expected_version}" +} + +install_nextest + +if ! tool_has_version git-cliff "${git_cliff_version}"; then + install_with_binary_fallback "${stable_toolchain}" git-cliff --version "${git_cliff_version}" +fi + +if [ "${mode}" = "strict" ]; then + if ! tool_has_version cargo-deny "${cargo_deny_version}"; then + install_with_binary_fallback "${stable_toolchain}" cargo-deny --version "${cargo_deny_version}" + fi + if ! cargo +"${nightly_toolchain}" udeps --version 2>/dev/null | grep -q " ${cargo_udeps_version}"; then + install_with_binary_fallback "${nightly_toolchain}" cargo-udeps --version "${cargo_udeps_version}" + fi + if ! tool_has_version cargo-semver-checks "${cargo_semver_checks_version}"; then + install_with_binary_fallback "${stable_toolchain}" cargo-semver-checks --version "${cargo_semver_checks_version}" + fi +fi + +cargo nextest --version +git-cliff --version + +if [ "${mode}" = "strict" ]; then + cargo deny --version + cargo +"${nightly_toolchain}" udeps --version + cargo semver-checks --version +fi diff --git a/scripts/ci/install-linux-deps.sh b/scripts/ci/install-linux-deps.sh new file mode 100755 index 0000000..625cbae --- /dev/null +++ b/scripts/ci/install-linux-deps.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +missing=() +for command_name in cc curl git jq pkg-config xz; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + missing+=("${command_name}") + fi +done +if command -v pkg-config >/dev/null 2>&1; then + for package_name in libssh2 openssl zlib; do + if ! pkg-config --exists "${package_name}"; then + missing+=("pkg-config:${package_name}") + fi + done +fi + +if [ "${#missing[@]}" -eq 0 ]; then + echo "Linux build dependencies are already available" + exit 0 +fi + +if [ "$(id -u)" -ne 0 ]; then + printf 'missing Linux build dependencies: %s\n' "${missing[*]}" >&2 + echo "run this CI job as root or bake the missing dependencies into the runner image" >&2 + exit 1 +fi + +apt-get update +apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + jq \ + libssh2-1-dev \ + libssl-dev \ + pkg-config \ + xz-utils \ + zlib1g-dev +rm -rf /var/lib/apt/lists/* diff --git a/scripts/ci/publish-gitea-release.sh b/scripts/ci/publish-gitea-release.sh new file mode 100755 index 0000000..5632b14 --- /dev/null +++ b/scripts/ci/publish-gitea-release.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_env() { + local name="$1" + if [ -z "${!name:-}" ]; then + echo "missing required environment variable: ${name}" >&2 + exit 1 + fi +} + +require_env GITEA_SERVER_URL +require_env GITEA_REPOSITORY +require_env GITEA_TOKEN +require_env RELEASE_TAG +require_env RELEASE_NAME +require_env RELEASE_VERSION +require_env RELEASE_TARGET + +server="${GITEA_SERVER_URL%/}" +repo="${GITEA_REPOSITORY}" +tag="${RELEASE_TAG}" +name="${RELEASE_NAME}" +version="${RELEASE_VERSION}" +target="${RELEASE_TARGET}" +draft="${RELEASE_DRAFT:-false}" +prerelease="${RELEASE_PRERELEASE:-false}" +notes_file="${RELEASE_BODY_FILE:-}" + +if [ -z "${notes_file}" ]; then + echo "missing required environment variable: RELEASE_BODY_FILE" >&2 + exit 1 +fi +if [ ! -f "${notes_file}" ]; then + echo "release body file does not exist: ${notes_file}" >&2 + exit 1 +fi +body="$(cat "${notes_file}")" + +asset_name_for() { + local family="$1" + local file="$2" + local base_name + base_name="$(basename "${file}")" + + if [ "${base_name}" = "SHA256SUMS.txt" ]; then + printf '%s-SHA256SUMS.txt' "${family}" + else + printf '%s' "${base_name}" + fi +} + +release_dir="dist/release/v${version}" +docker_dir="dist/docker/v${version}" + +declare -a asset_files=() +declare -a asset_names=() + +if [ -d "${release_dir}" ]; then + while IFS= read -r -d '' file; do + asset_files+=("${file}") + asset_names+=("$(asset_name_for release "${file}")") + done < <(find "${release_dir}" -maxdepth 1 -type f -print0 | sort -z) +fi + +if [ -d "${docker_dir}" ]; then + while IFS= read -r -d '' file; do + asset_files+=("${file}") + asset_names+=("$(asset_name_for docker "${file}")") + done < <(find "${docker_dir}" -maxdepth 1 -type f -print0 | sort -z) +fi + +if [ "${#asset_files[@]}" -eq 0 ]; then + echo "no release assets found under ${release_dir} or ${docker_dir}" >&2 + exit 1 +fi + +api_base="${server}/api/v1/repos/${repo}" +auth_header="Authorization: token ${GITEA_TOKEN}" +accept_header="Accept: application/json" +curl_args=(-sS) + +if [ "${GITEA_CURL_INSECURE:-false}" = "true" ]; then + curl_args+=(-k) +fi + +release_lookup="$(mktemp)" +release_payload="$(mktemp)" +trap 'rm -f "${release_lookup}" "${release_payload}"' EXIT + +lookup_status="$( + curl "${curl_args[@]}" -o "${release_lookup}" -w '%{http_code}' \ + -H "${auth_header}" \ + -H "${accept_header}" \ + "${api_base}/releases/tags/${tag}" +)" + +printf '%s' "${body}" | jq -Rs \ + --arg tag "${tag}" \ + --arg name "${name}" \ + --arg target "${target}" \ + --argjson draft "${draft}" \ + --argjson prerelease "${prerelease}" \ + '{tag_name:$tag,name:$name,target_commitish:$target,body:.,draft:$draft,prerelease:$prerelease}' \ + > "${release_payload}" + +if [ "${lookup_status}" = "200" ]; then + release_id="$(jq -r '.id' "${release_lookup}")" + curl -f "${curl_args[@]}" \ + -X PATCH \ + -H "${auth_header}" \ + -H "${accept_header}" \ + -H 'Content-Type: application/json' \ + --data @"${release_payload}" \ + "${api_base}/releases/${release_id}" \ + > "${release_lookup}" +elif [ "${lookup_status}" = "404" ]; then + curl -f "${curl_args[@]}" \ + -X POST \ + -H "${auth_header}" \ + -H "${accept_header}" \ + -H 'Content-Type: application/json' \ + --data @"${release_payload}" \ + "${api_base}/releases" \ + > "${release_lookup}" + release_id="$(jq -r '.id' "${release_lookup}")" +else + echo "failed to look up release ${tag}: Gitea returned HTTP ${lookup_status}" >&2 + cat "${release_lookup}" >&2 + exit 1 +fi + +release_id="${release_id:-$(jq -r '.id' "${release_lookup}")}" + +for index in "${!asset_files[@]}"; do + file="${asset_files[${index}]}" + asset_name="${asset_names[${index}]}" + existing_asset_id="$( + jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .id' "${release_lookup}" \ + | head -n 1 + )" + + if [ -n "${existing_asset_id}" ] && [ "${existing_asset_id}" != "null" ]; then + curl -f "${curl_args[@]}" \ + -X DELETE \ + -H "${auth_header}" \ + -H "${accept_header}" \ + "${api_base}/releases/${release_id}/assets/${existing_asset_id}" \ + >/dev/null + fi + + encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${asset_name}")" + + curl -f "${curl_args[@]}" \ + -X POST \ + -H "${auth_header}" \ + -H "${accept_header}" \ + -F "attachment=@${file}" \ + "${api_base}/releases/${release_id}/assets?name=${encoded_name}" \ + >/dev/null +done + +release_url="$(jq -r '.html_url // empty' "${release_lookup}")" +echo "Published release ${name} (${tag})" +if [ -n "${release_url}" ]; then + echo "Release URL: ${release_url}" +fi diff --git a/scripts/ci/run-fast-gates.sh b/scripts/ci/run-fast-gates.sh new file mode 100755 index 0000000..d534dfe --- /dev/null +++ b/scripts/ci/run-fast-gates.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -f "${HOME}/.cargo/env" ]; then + # shellcheck disable=SC1091 + source "${HOME}/.cargo/env" +fi +command -v cargo >/dev/null 2>&1 || { + echo "cargo is required to run fast gates" >&2 + exit 1 +} + +cargo fmt --all --check +cargo check --workspace --all-targets --all-features --locked +cargo nextest run --workspace --all-targets --all-features --locked +# `clippy::cargo` spawns nested `cargo metadata` from clippy-driver. On the +# NAS runner this can wait behind the parent cargo/clippy target lock; keep +# dependency policy in strict gates via cargo-deny and cargo-udeps instead. +cargo clippy --workspace --all-targets --all-features --locked --no-deps -- \ + -D warnings \ + -D clippy::pedantic \ + -D clippy::nursery diff --git a/scripts/ci/run-semver-checks.sh b/scripts/ci/run-semver-checks.sh new file mode 100755 index 0000000..da65e00 --- /dev/null +++ b/scripts/ci/run-semver-checks.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_type() { + local current="${1#v}" + local baseline="${2#v}" + local current_major current_minor baseline_major baseline_minor + + IFS='.' read -r current_major current_minor _ <<< "${current}" + IFS='.' read -r baseline_major baseline_minor _ <<< "${baseline}" + + if [ "${current_major}" != "${baseline_major}" ]; then + printf '%s\n' major + elif [ "${current_minor}" != "${baseline_minor}" ]; then + printf '%s\n' minor + else + printf '%s\n' patch + fi +} + +run_self_test() { + [ "$(release_type v2.0.0 v1.9.9)" = major ] + [ "$(release_type v1.3.0 v1.2.9)" = minor ] + [ "$(release_type v1.2.4 v1.2.3)" = patch ] +} + +if [ "${1:-}" = "--self-test" ]; then + run_self_test + echo "SemVer release-type self-test passed" + exit 0 +fi + +release_tag="${1:?release tag is required}" +git rev-parse --verify "${release_tag}^{commit}" >/dev/null + +baseline_tag="$({ git tag --merged "${release_tag}" --sort=-version:refname | grep -Fxv "${release_tag}" || true; } | head -n 1)" +if [ -z "${baseline_tag}" ]; then + echo "No earlier SemVer tag before ${release_tag}; skipping baseline comparison" + exit 0 +fi + +release_kind="$(release_type "${release_tag}" "${baseline_tag}")" +for package in \ + aria2-rust-pro-cli \ + aria2-rust-pro-compat \ + aria2-rust-pro-core \ + aria2-rust-pro-protocol \ + aria2-rust-pro-rpc \ + aria2-rust-pro-storage; do + cargo semver-checks check-release \ + --package "${package}" \ + --baseline-rev "${baseline_tag}" \ + --release-type "${release_kind}" +done diff --git a/scripts/ci/run-strict-gates.sh b/scripts/ci/run-strict-gates.sh new file mode 100755 index 0000000..c48bc40 --- /dev/null +++ b/scripts/ci/run-strict-gates.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -f "${HOME}/.cargo/env" ]; then + # shellcheck disable=SC1091 + source "${HOME}/.cargo/env" +fi +command -v cargo >/dev/null 2>&1 || { + echo "cargo is required to run strict gates" >&2 + exit 1 +} + +# cargo-udeps injects sysroot crates on nightly; let udeps report real unused deps. +nightly_toolchain="${CARGO_NIGHTLY_TOOLCHAIN:-nightly}" +RUSTFLAGS="-A unused-crate-dependencies" cargo +"${nightly_toolchain}" udeps --workspace --all-targets --all-features --locked + +deny_db_path="${CARGO_DENY_DB_PATH:-target/cargo-deny-advisory-dbs}" +deny_config="${CARGO_DENY_CONFIG:-deny.toml}" +mkdir -p "${deny_db_path}" + +cargo_deny() { + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW* | MSYS* | CYGWIN*) + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.sslbackend \ + GIT_CONFIG_VALUE_0=openssl \ + cargo deny "$@" + ;; + *) + cargo deny "$@" + ;; + esac +} + +dump_deny_db_state() { + echo "cargo deny version: $(cargo deny --version 2>/dev/null || true)" >&2 + echo "git version: $(git --version 2>/dev/null || true)" >&2 + echo "cargo deny db path: ${deny_db_path}" >&2 + if [ -d "${deny_db_path}" ]; then + find "${deny_db_path}" -maxdepth 2 -type d -print >&2 + else + echo "cargo deny db path does not exist" >&2 + fi +} + +fetch_deny_db() { + cargo_deny fetch db --config "${deny_config}" +} + +if ! fetch_deny_db; then + dump_deny_db_state + echo "retrying cargo deny advisory DB fetch after clearing ${deny_db_path}" >&2 + rm -rf "${deny_db_path}" + mkdir -p "${deny_db_path}" + if ! fetch_deny_db; then + dump_deny_db_state + exit 1 + fi +fi +if ! find "${deny_db_path}" -mindepth 1 -maxdepth 1 -type d -name 'advisory-db-*' | grep -q .; then + echo "cargo deny did not populate advisory DB under ${deny_db_path}" >&2 + dump_deny_db_state + exit 1 +fi + +cargo_deny --locked check --disable-fetch --config "${deny_config}" + +cargo run --manifest-path ./xtask/Cargo.toml -- release smoke-version --build +cargo run --manifest-path ./xtask/Cargo.toml -- docker smoke-local +command -v docker >/dev/null 2>&1 || { + echo "docker CLI is required for daemon-backed Docker smoke tests" >&2 + exit 1 +} +docker info >/dev/null 2>&1 || { + echo "docker daemon is required for daemon-backed Docker smoke tests" >&2 + echo "attach a Docker-capable runner or split this gate to a Docker runner label" >&2 + exit 1 +} +cargo run --manifest-path ./xtask/Cargo.toml -- docker smoke --tag aria2-rust-pro:ci-smoke diff --git a/scripts/compat/capture-cli-goldens.ps1 b/scripts/compat/capture-cli-goldens.ps1 new file mode 100644 index 0000000..cfab798 --- /dev/null +++ b/scripts/compat/capture-cli-goldens.ps1 @@ -0,0 +1,33 @@ +param( + [string]$UpstreamBinary = "", + [string]$RustBinary = "", + [string]$OutputRoot = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "compat", + "capture-cli-goldens" +) + +if (-not [string]::IsNullOrWhiteSpace($UpstreamBinary)) { + $arguments += @("--upstream-binary", $UpstreamBinary) +} +if (-not [string]::IsNullOrWhiteSpace($RustBinary)) { + $arguments += @("--rust-binary", $RustBinary) +} +if (-not [string]::IsNullOrWhiteSpace($OutputRoot)) { + $arguments += @("--output-root", $OutputRoot) +} + +rtk @arguments diff --git a/scripts/docker/export-local.ps1 b/scripts/docker/export-local.ps1 new file mode 100644 index 0000000..749fd04 --- /dev/null +++ b/scripts/docker/export-local.ps1 @@ -0,0 +1,32 @@ +param( + [string]$Tag = "aria2-rust-pro:local", + [string]$OutputRoot, + [switch]$Build +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "docker", + "export-local", + "--tag", + $Tag +) + +if (-not [string]::IsNullOrWhiteSpace($OutputRoot)) { + $arguments += @("--output-root", $OutputRoot) +} +if ($Build) { + $arguments += "--build" +} + +rtk @arguments diff --git a/scripts/docker/smoke-local.ps1 b/scripts/docker/smoke-local.ps1 new file mode 100644 index 0000000..c3e542c --- /dev/null +++ b/scripts/docker/smoke-local.ps1 @@ -0,0 +1,19 @@ +param() + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "docker", + "smoke-local" +) + +rtk @arguments diff --git a/scripts/docker/smoke.ps1 b/scripts/docker/smoke.ps1 new file mode 100644 index 0000000..0795582 --- /dev/null +++ b/scripts/docker/smoke.ps1 @@ -0,0 +1,36 @@ +param( + [string]$Tag = "aria2-rust-pro:smoke", + [bool]$Build = $true, + [int]$HostRpcPort = 26800, + [string]$RpcSecret = "smoke-secret", + [string]$SpecialMode = "move" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" +$buildValue = $Build.ToString().ToLowerInvariant() + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "docker", + "smoke", + "--tag", + $Tag, + "--build", + $buildValue, + "--host-rpc-port", + "$HostRpcPort", + "--rpc-secret", + $RpcSecret, + "--special-mode", + $SpecialMode +) + +rtk @arguments diff --git a/scripts/perf/collect_local_comparison.ps1 b/scripts/perf/collect_local_comparison.ps1 new file mode 100644 index 0000000..256764f --- /dev/null +++ b/scripts/perf/collect_local_comparison.ps1 @@ -0,0 +1,45 @@ +param( + [string]$OutputPath, + [double]$SampleSize = 10, + [double]$MeasurementSeconds = 0.05, + [double]$WarmupSeconds = 0.05, + [int]$TransferBytes = 8388608, + [switch]$SkipBenchExecution +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$OutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) { + Join-Path $repoRoot "docs\perf\local-comparison.md" +} else { + $OutputPath +} +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "perf", + "collect-local-comparison", + "--output-path", + $OutputPath, + "--sample-size", + "$SampleSize", + "--measurement-seconds", + "$MeasurementSeconds", + "--warmup-seconds", + "$WarmupSeconds", + "--transfer-bytes", + "$TransferBytes" +) + +if ($SkipBenchExecution) { + $arguments += "--skip-bench-execution" +} + +rtk @arguments diff --git a/scripts/perf/profile_shared_runtime_http_pressure.ps1 b/scripts/perf/profile_shared_runtime_http_pressure.ps1 new file mode 100644 index 0000000..5fda5e3 --- /dev/null +++ b/scripts/perf/profile_shared_runtime_http_pressure.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [ValidateSet("samply", "flamegraph")] + [string]$Profiler = "samply", + [int]$ProfileSeconds = 4, + [string]$Scenario = "shared_runtime_live_http_transfer/tight_cap_6way", + [string]$OutputDir +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$OutputDir = if ([string]::IsNullOrWhiteSpace($OutputDir)) { + Join-Path $repoRoot "artifacts\shared-runtime-http-pressure-profile" +} else { + $OutputDir +} +$manifestPath = Join-Path $repoRoot "Cargo.toml" +$null = New-Item -ItemType Directory -Path $OutputDir -Force +$artifactStem = ($Scenario -replace "[^A-Za-z0-9._-]", "-").Trim("-") +if ([string]::IsNullOrWhiteSpace($artifactStem)) { + throw "Scenario must produce a non-empty artifact stem." +} + +$cargoArgs = @( + "bench", + "-p", + "aria2-rust-pro-tests", + "--bench", + "rpc_pressure", + $Scenario, + "--manifest-path", + $manifestPath, + "--", + "--profile-time", + $ProfileSeconds.ToString() +) + +$metaPath = Join-Path $OutputDir "meta.txt" +@( + "timestamp=$([DateTimeOffset]::Now.ToString('O'))" + "profiler=$Profiler" + "profile_seconds=$ProfileSeconds" + "scenario=$Scenario" +) | Set-Content -LiteralPath $metaPath -Encoding UTF8 + +Push-Location $repoRoot +try { + switch ($Profiler) { + "samply" { + $outputPath = Join-Path $OutputDir "$artifactStem-profile.json.gz" + & samply record ` + --save-only ` + --no-open ` + --output $outputPath ` + --profile-name "shared-runtime-http-pressure" ` + cargo @cargoArgs + } + "flamegraph" { + $outputPath = Join-Path $OutputDir "$artifactStem-flamegraph.svg" + & cargo flamegraph ` + "-p" "aria2-rust-pro-tests" ` + "--bench" "rpc_pressure" ` + "--manifest-path" $manifestPath ` + "--output" $outputPath ` + "--" ` + $Scenario ` + "--profile-time" ` + $ProfileSeconds + } + default { + throw "Unsupported profiler: $Profiler" + } + } + + if ($LASTEXITCODE -ne 0) { + throw "Profiler command failed with exit code $LASTEXITCODE." + } +} +finally { + Pop-Location +} diff --git a/scripts/perf/profile_shared_runtime_http_pressure_admin.ps1 b/scripts/perf/profile_shared_runtime_http_pressure_admin.ps1 new file mode 100644 index 0000000..e496175 --- /dev/null +++ b/scripts/perf/profile_shared_runtime_http_pressure_admin.ps1 @@ -0,0 +1,49 @@ +[CmdletBinding()] +param( + [ValidateSet("samply", "flamegraph")] + [string]$Profiler = "flamegraph", + [int]$ProfileSeconds = 4, + [string]$Scenario = "shared_runtime_live_http_transfer/tight_cap_6way", + [string]$OutputDir +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$OutputDir = if ([string]::IsNullOrWhiteSpace($OutputDir)) { + Join-Path $repoRoot "artifacts\shared-runtime-http-pressure-profile" +} else { + $OutputDir +} +$profileScript = Join-Path $repoRoot "scripts\perf\profile_shared_runtime_http_pressure.ps1" + +$msudoArgs = @( + "--same-console", + "--wait", + "--user", + "admin", + "--current-directory", + $repoRoot, + "--", + "pwsh", + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $profileScript, + "-Profiler", + $Profiler, + "-ProfileSeconds", + $ProfileSeconds.ToString(), + "-Scenario", + $Scenario, + "-OutputDir", + $OutputDir +) + +& msudo @msudoArgs +if ($LASTEXITCODE -ne 0) { + throw "Elevated profiler command failed with exit code $LASTEXITCODE." +} diff --git a/scripts/perf/run_admin_command.ps1 b/scripts/perf/run_admin_command.ps1 new file mode 100644 index 0000000..83cb462 --- /dev/null +++ b/scripts/perf/run_admin_command.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Command, + [string]$OutputDir, + [switch]$AllowNonZeroExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$OutputDir = if ([string]::IsNullOrWhiteSpace($OutputDir)) { + Join-Path $repoRoot "artifacts\admin-command" +} else { + $OutputDir +} +$null = New-Item -ItemType Directory -Path $OutputDir -Force + +$stdoutPath = Join-Path $OutputDir "stdout.txt" +$stderrPath = Join-Path $OutputDir "stderr.txt" +$metaPath = Join-Path $OutputDir "meta.txt" +$exitCodePath = Join-Path $OutputDir "exit-code.txt" + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + +@( + "timestamp=$([DateTimeOffset]::Now.ToString('O'))" + "is_admin=$isAdmin" +) | Set-Content -LiteralPath $metaPath -Encoding UTF8 + +$psi = [System.Diagnostics.ProcessStartInfo]::new() +$psi.FileName = "cmd.exe" +$psi.Arguments = "/d /c $Command" +$psi.WorkingDirectory = (Get-Location).Path +$psi.UseShellExecute = $false +$psi.RedirectStandardOutput = $true +$psi.RedirectStandardError = $true + +$process = [System.Diagnostics.Process]::Start($psi) +if ($null -eq $process) { + throw "Failed to start command." +} + +$stdout = $process.StandardOutput.ReadToEnd() +$stderr = $process.StandardError.ReadToEnd() +$process.WaitForExit() + +Set-Content -LiteralPath $stdoutPath -Value $stdout -Encoding UTF8 +Set-Content -LiteralPath $stderrPath -Value $stderr -Encoding UTF8 +Set-Content -LiteralPath $exitCodePath -Value "$($process.ExitCode)" -Encoding UTF8 + +if (-not $AllowNonZeroExit -and $process.ExitCode -ne 0) { + throw "Command failed with exit code $($process.ExitCode)." +} diff --git a/scripts/perf/run_admin_probe.ps1 b/scripts/perf/run_admin_probe.ps1 new file mode 100644 index 0000000..a6c77ab --- /dev/null +++ b/scripts/perf/run_admin_probe.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string]$OutputDir, + [string]$Command = "whoami /all", + [switch]$AllowNonZeroExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$OutputDir = if ([string]::IsNullOrWhiteSpace($OutputDir)) { + Join-Path $repoRoot "artifacts\admin-probe" +} else { + $OutputDir +} +$null = New-Item -ItemType Directory -Path $OutputDir -Force + +$stdoutPath = Join-Path $OutputDir "stdout.txt" +$stderrPath = Join-Path $OutputDir "stderr.txt" +$metaPath = Join-Path $OutputDir "meta.txt" + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + +@( + "timestamp=$([DateTimeOffset]::Now.ToString('O'))" + "is_admin=$isAdmin" +) | Set-Content -LiteralPath $metaPath -Encoding UTF8 + +$psi = [System.Diagnostics.ProcessStartInfo]::new() +$psi.FileName = "cmd.exe" +$psi.Arguments = "/d /c $Command" +$psi.WorkingDirectory = (Get-Location).Path +$psi.UseShellExecute = $false +$psi.RedirectStandardOutput = $true +$psi.RedirectStandardError = $true + +$process = [System.Diagnostics.Process]::Start($psi) +if ($null -eq $process) { + throw "Failed to start probe command." +} + +$stdout = $process.StandardOutput.ReadToEnd() +$stderr = $process.StandardError.ReadToEnd() +$process.WaitForExit() + +Set-Content -LiteralPath $stdoutPath -Value $stdout -Encoding UTF8 +Set-Content -LiteralPath $stderrPath -Value $stderr -Encoding UTF8 + +if (-not $AllowNonZeroExit -and $process.ExitCode -ne 0) { + throw "Probe command failed with exit code $($process.ExitCode)." +} diff --git a/scripts/release/package-local.ps1 b/scripts/release/package-local.ps1 new file mode 100644 index 0000000..82e2452 --- /dev/null +++ b/scripts/release/package-local.ps1 @@ -0,0 +1,41 @@ +param( + [switch]$Build, + [string]$TargetTriple, + [string]$SourceBinary, + [string]$OutputRoot, + [switch]$SkipVersionSmoke +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "release", + "package-local" +) + +if ($Build) { + $arguments += "--build" +} +if (-not [string]::IsNullOrWhiteSpace($TargetTriple)) { + $arguments += @("--target-triple", $TargetTriple) +} +if (-not [string]::IsNullOrWhiteSpace($SourceBinary)) { + $arguments += @("--source-binary", $SourceBinary) +} +if (-not [string]::IsNullOrWhiteSpace($OutputRoot)) { + $arguments += @("--output-root", $OutputRoot) +} +if ($SkipVersionSmoke) { + $arguments += "--skip-version-smoke" +} + +rtk @arguments diff --git a/scripts/release/smoke-version.ps1 b/scripts/release/smoke-version.ps1 new file mode 100644 index 0000000..920d983 --- /dev/null +++ b/scripts/release/smoke-version.ps1 @@ -0,0 +1,33 @@ +param( + [switch]$Build, + [string]$TargetTriple, + [string]$BinaryPath +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "release", + "smoke-version" +) + +if ($Build) { + $arguments += "--build" +} +if (-not [string]::IsNullOrWhiteSpace($TargetTriple)) { + $arguments += @("--target-triple", $TargetTriple) +} +if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { + $arguments += @("--binary-path", $BinaryPath) +} + +rtk @arguments diff --git a/scripts/testing/strict-sweep.ps1 b/scripts/testing/strict-sweep.ps1 new file mode 100644 index 0000000..1e26f91 --- /dev/null +++ b/scripts/testing/strict-sweep.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [switch]$IncludeReleaseSmoke +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$xtaskManifest = Join-Path $repoRoot "xtask\Cargo.toml" + +$arguments = @( + "cargo", + "run", + "--manifest-path", + $xtaskManifest, + "--", + "testing", + "strict-sweep" +) + +if ($IncludeReleaseSmoke) { + $arguments += "--include-release-smoke" +} + +rtk @arguments diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..5fa2f71 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "xtask" +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 = "xtask" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.100" +cargo_metadata = "0.23.1" +clap = { version = "4.5.53", features = ["derive"] } +flate2 = "1.1.5" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = "0.10.9" +tar = "0.4.44" +tempfile = "3.23.0" +time = { version = "0.3.47", features = ["formatting"] } +walkdir = "2.5.0" +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } + +[lints] +workspace = true diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs new file mode 100644 index 0000000..ee9b332 --- /dev/null +++ b/xtask/src/cli.rs @@ -0,0 +1,237 @@ +#![expect( + clippy::redundant_pub_crate, + reason = "the cli module stays private while sibling xtask modules need crate-visible CLI types" +)] + +use std::path::PathBuf; + +use clap::{ArgAction, Args, Parser, Subcommand}; + +/// Top-level xtask CLI entrypoint. +#[derive(Debug, Parser)] +#[command( + author, + version, + about = "Cargo-native workflow entrypoints for aria2-rust-pro" +)] +pub(crate) struct Cli { + /// Selected top-level subcommand. + #[command(subcommand)] + pub(crate) command: TopLevelCommand, +} + +/// Supported top-level xtask command groups. +#[derive(Debug, Subcommand)] +pub(crate) enum TopLevelCommand { + /// Compatibility and golden-output workflows. + Compat(CompatCommand), + /// Docker packaging and smoke-test workflows. + Docker(DockerCommand), + /// Performance collection workflows. + Perf(PerfCommand), + /// Release packaging and validation workflows. + Release(ReleaseCommand), + /// Aggregated testing and lint-style sweeps. + Testing(TestingCommand), +} + +/// Compatibility command group. +#[derive(Debug, Args)] +pub(crate) struct CompatCommand { + /// Selected compatibility subcommand. + #[command(subcommand)] + pub(crate) command: CompatSubcommand, +} + +/// Compatibility-focused subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum CompatSubcommand { + /// Capture CLI output goldens from upstream and Rust binaries. + CaptureCliGoldens(CaptureCliGoldensArgs), +} + +/// Arguments for capturing CLI golden outputs. +#[derive(Debug, Args)] +pub(crate) struct CaptureCliGoldensArgs { + /// Optional path to the upstream reference binary. + #[arg(long)] + pub(crate) upstream_binary: Option, + /// Optional path to the Rust implementation binary. + #[arg(long)] + pub(crate) rust_binary: Option, + /// Optional root directory where captured goldens are written. + #[arg(long)] + pub(crate) output_root: Option, +} + +/// Release command group. +#[derive(Debug, Args)] +pub(crate) struct ReleaseCommand { + /// Selected release subcommand. + #[command(subcommand)] + pub(crate) command: ReleaseSubcommand, +} + +/// Release-oriented subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum ReleaseSubcommand { + /// Validate the release binary reports the expected version string. + SmokeVersion(ReleaseSmokeVersionArgs), + /// Package a local release artifact layout. + PackageLocal(PackageLocalArgs), +} + +/// Arguments for the release version smoke test. +#[derive(Debug, Args)] +pub(crate) struct ReleaseSmokeVersionArgs { + /// Build the binary before running the smoke test. + #[arg(long)] + pub(crate) build: bool, + /// Optional target triple used when locating or building the binary. + #[arg(long)] + pub(crate) target_triple: Option, + /// Optional explicit path to the binary under test. + #[arg(long)] + pub(crate) binary_path: Option, +} + +/// Arguments for packaging a local release bundle. +#[derive(Debug, Args)] +pub(crate) struct PackageLocalArgs { + /// Build the binary before packaging it. + #[arg(long)] + pub(crate) build: bool, + /// Optional target triple used when locating or building the binary. + #[arg(long)] + pub(crate) target_triple: Option, + /// Optional explicit path to the binary that should be packaged. + #[arg(long)] + pub(crate) source_binary: Option, + /// Optional root directory where packaged artifacts are written. + #[arg(long)] + pub(crate) output_root: Option, + /// Skip the version smoke test that normally runs before packaging. + #[arg(long)] + pub(crate) skip_version_smoke: bool, +} + +/// Testing command group. +#[derive(Debug, Args)] +pub(crate) struct TestingCommand { + /// Selected testing subcommand. + #[command(subcommand)] + pub(crate) command: TestingSubcommand, +} + +/// Testing-oriented subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum TestingSubcommand { + /// Run the strict sweep across the xtask quality gates. + StrictSweep(StrictSweepArgs), +} + +/// Arguments for the strict sweep workflow. +#[derive(Debug, Args)] +pub(crate) struct StrictSweepArgs { + /// Include release smoke checks as part of the sweep. + #[arg(long)] + pub(crate) include_release_smoke: bool, +} + +/// Docker command group. +#[derive(Debug, Args)] +pub(crate) struct DockerCommand { + /// Selected Docker subcommand. + #[command(subcommand)] + pub(crate) command: DockerSubcommand, +} + +/// Docker-oriented subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum DockerSubcommand { + /// Export a locally built Docker image as a tarball. + ExportLocal(ExportLocalArgs), + /// Run the Docker smoke test against a tagged image. + Smoke(DockerSmokeArgs), + /// Run the Docker smoke test using the local defaults. + SmokeLocal(DockerSmokeLocalArgs), +} + +/// Arguments for exporting a local Docker image. +#[derive(Debug, Args)] +pub(crate) struct ExportLocalArgs { + /// Docker image tag to build or export. + #[arg(long, default_value = "aria2-rust-pro:local")] + pub(crate) tag: String, + /// Optional root directory where exported artifacts are written. + #[arg(long)] + pub(crate) output_root: Option, + /// Build the Docker image before exporting it. + #[arg(long)] + pub(crate) build: bool, +} + +/// Arguments for running the Docker smoke test. +#[derive(Debug, Args)] +pub(crate) struct DockerSmokeArgs { + /// Docker image tag to build or run during the smoke test. + #[arg(long, default_value = "aria2-rust-pro:smoke")] + pub(crate) tag: String, + /// Build the Docker image before running the smoke test. + #[arg(long, default_value_t = true, action = ArgAction::Set)] + pub(crate) build: bool, + /// Host RPC port mapped into the smoke-test container. + #[arg(long, default_value_t = 26_800)] + pub(crate) host_rpc_port: u16, + /// RPC secret used by the smoke-test container. + #[arg(long, default_value = "smoke-secret")] + pub(crate) rpc_secret: String, + /// Special mode passed into the smoke-test scenario. + #[arg(long, default_value = "move")] + pub(crate) special_mode: String, +} + +/// Arguments for the local Docker smoke shortcut. +#[derive(Debug, Args)] +pub(crate) struct DockerSmokeLocalArgs {} + +/// Performance command group. +#[derive(Debug, Args)] +pub(crate) struct PerfCommand { + /// Selected performance subcommand. + #[command(subcommand)] + pub(crate) command: PerfSubcommand, +} + +/// Performance-oriented subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum PerfSubcommand { + /// Collect local upstream-vs-Rust comparison data. + CollectLocalComparison(CollectLocalComparisonArgs), +} + +/// Arguments for local comparison data collection. +#[derive(Debug, Args)] +pub(crate) struct CollectLocalComparisonArgs { + /// Optional output file for the comparison report. + #[arg(long)] + pub(crate) output_path: Option, + /// Number of samples collected per benchmark case. + #[arg(long, default_value_t = 10.0)] + pub(crate) sample_size: f64, + /// Measurement duration, in seconds, for each sample. + #[arg(long, default_value_t = 0.05)] + pub(crate) measurement_seconds: f64, + /// Warmup duration, in seconds, before measurements begin. + #[arg(long, default_value_t = 0.05)] + pub(crate) warmup_seconds: f64, + /// Transfer size, in bytes, used by the benchmark. + #[arg(long, default_value_t = 8 * 1024 * 1024)] + pub(crate) transfer_bytes: usize, + /// Timed same-host transfer samples collected per binary and scenario after warmup. + #[arg(long, default_value_t = 5)] + pub(crate) same_host_runs: usize, + /// Skip bench execution and only reuse existing output data. + #[arg(long)] + pub(crate) skip_bench_execution: bool, +} diff --git a/xtask/src/compat.rs b/xtask/src/compat.rs new file mode 100644 index 0000000..77c9af2 --- /dev/null +++ b/xtask/src/compat.rs @@ -0,0 +1,168 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; + +use crate::{ + cli::CaptureCliGoldensArgs, + workspace::{ + capture_stdout_lines, default_binary_path, load_workspace_metadata, utc_now_rfc3339, + write_utf8_lines, write_utf8_text, + }, +}; + +#[derive(Debug, Serialize)] +/// JSON manifest written beside captured CLI compatibility goldens. +struct CompatManifest { + /// UTC generation timestamp for the manifest. + generated_at_utc: String, + /// Relative paths captured by the workflow. + captured: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +/// Human-readable JSON summary printed after capture. +struct CompatSummary { + #[serde(rename = "Captured")] + /// Relative paths captured by the workflow. + captured: Vec<&'static str>, +} + +/// Captures upstream and Rust CLI help/version output as compatibility goldens. +pub fn run_capture_cli_goldens(args: &CaptureCliGoldensArgs) -> Result<()> { + let metadata = load_workspace_metadata()?; + let host_triple = crate::workspace::host_triple()?; + let upstream_binary = args + .upstream_binary + .clone() + .unwrap_or_else(default_upstream_binary); + let rust_binary = args + .rust_binary + .clone() + .unwrap_or_else(|| default_binary_path(&metadata, &host_triple, &host_triple)); + let output_root = args.output_root.clone().unwrap_or_else(|| { + metadata + .workspace_root + .join("docs/compatibility/goldens/cli") + }); + + ensure_path_exists(&upstream_binary, "upstream binary")?; + ensure_path_exists(&rust_binary, "rust binary")?; + + let upstream_dir = output_root.join("upstream"); + let rust_dir = output_root.join("rust"); + std::fs::create_dir_all(&upstream_dir) + .with_context(|| format!("failed to create {}", upstream_dir.display()))?; + std::fs::create_dir_all(&rust_dir) + .with_context(|| format!("failed to create {}", rust_dir.display()))?; + + let upstream_version = capture_stdout_lines(&upstream_binary, &["-v"])?; + let upstream_help = + redact_windows_home_paths(capture_stdout_lines(&upstream_binary, &["--help=#all"])?); + let rust_version = capture_stdout_lines(&rust_binary, &["--version"])?; + let rust_help = + redact_windows_home_paths(capture_stdout_lines(&rust_binary, &["--help=#all"])?); + + write_utf8_lines(&upstream_dir.join("version.txt"), &upstream_version)?; + write_utf8_lines(&upstream_dir.join("help-all.txt"), &upstream_help)?; + write_utf8_lines(&rust_dir.join("version.txt"), &rust_version)?; + write_utf8_lines(&rust_dir.join("help-all.txt"), &rust_help)?; + + let captured = captured_paths(); + let manifest = CompatManifest { + generated_at_utc: utc_now_rfc3339()?, + captured: captured.to_vec(), + }; + let manifest_text = serde_json::to_string_pretty(&manifest) + .context("failed to serialize compatibility manifest")?; + write_utf8_text(&output_root.join("manifest.json"), &(manifest_text + "\n"))?; + + let summary = CompatSummary { + captured: summary_paths().to_vec(), + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize compatibility summary")? + ); + Ok(()) +} + +/// Returns the repository-local default upstream aria2 binary path. +fn default_upstream_binary() -> PathBuf { + crate::workspace::repo_root() + .join("dist") + .join("upstream") + .join("aria2-1.37.0-win-64bit-build1") + .join("aria2-1.37.0-win-64bit-build1") + .join("aria2c.exe") +} + +/// Ensures an expected binary path exists. +fn ensure_path_exists(path: &Path, label: &str) -> Result<()> { + if path.is_file() { + Ok(()) + } else { + bail!("{label} not found: {}", path.display()) + } +} + +/// Replaces a Windows account name embedded in captured CLI help text. +fn redact_windows_home_paths(lines: Vec) -> Vec { + lines + .into_iter() + .map(|line| redact_windows_home_path(&line)) + .collect() +} + +fn redact_windows_home_path(line: &str) -> String { + const PREFIX: &str = "C:/Users/"; + + let Some(start) = line.find(PREFIX) else { + return line.to_owned(); + }; + let user_start = start + PREFIX.len(); + let Some(user_length) = line[user_start..].find('/') else { + return line.to_owned(); + }; + let suffix_start = user_start + user_length + 1; + format!( + "{}C:/Users//{}", + &line[..start], + &line[suffix_start..] + ) +} + +#[cfg(test)] +mod tests { + use super::redact_windows_home_path; + + #[test] + fn redacts_a_windows_home_path_in_captured_help() { + assert_eq!( + redact_windows_home_path("Default: C:/Users/alice/.netrc"), + "Default: C:/Users//.netrc" + ); + } +} + +/// Returns the golden files produced by the capture workflow. +const fn captured_paths() -> &'static [&'static str] { + &[ + "upstream/version.txt", + "upstream/help-all.txt", + "rust/version.txt", + "rust/help-all.txt", + ] +} + +/// Returns all paths reported in the workflow summary. +const fn summary_paths() -> &'static [&'static str] { + &[ + "upstream/version.txt", + "upstream/help-all.txt", + "rust/version.txt", + "rust/help-all.txt", + "manifest.json", + ] +} diff --git a/xtask/src/docker.rs b/xtask/src/docker.rs new file mode 100644 index 0000000..95e65ab --- /dev/null +++ b/xtask/src/docker.rs @@ -0,0 +1,991 @@ +use std::{ + env, + ffi::{OsStr, OsString}, + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, + thread::sleep, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::{ + cli::{DockerSmokeArgs, DockerSmokeLocalArgs, ExportLocalArgs}, + workspace::{ + load_workspace_metadata, repo_root, run_command, run_command_with_env, utc_now_rfc3339, + write_utf8_text, + }, +}; + +#[derive(Debug, Serialize)] +/// Machine-readable manifest written beside a Docker image archive. +struct DockerArchiveManifest { + /// Package version used for the archive directory. + version: String, + /// Docker image tag exported by the workflow. + tag: String, + /// Docker image identifier reported by `docker image inspect`. + image_id: String, + /// Exported archive artifact metadata. + archive: DockerArchiveArtifact, + /// Path to the aggregate checksum list. + checksum_list: String, + /// UTC generation timestamp for the manifest. + generated_at_utc: String, +} + +#[derive(Debug, Serialize)] +/// Metadata for a Docker archive artifact. +struct DockerArchiveArtifact { + /// Archive file name. + file_name: String, + /// Full archive path. + path: String, + /// Hex-encoded SHA-256 digest. + sha256: String, +} + +#[derive(Debug, Serialize)] +/// Summary printed after exporting a local Docker image. +struct DockerExportSummary { + #[serde(rename = "Version")] + /// Package version used for the archive directory. + version: String, + #[serde(rename = "Tag")] + /// Docker image tag exported by the workflow. + tag: String, + #[serde(rename = "ArchivePath")] + /// Path to the exported Docker archive. + archive_path: String, + #[serde(rename = "ManifestPath")] + /// Path to the generated manifest. + manifest_path: String, + #[serde(rename = "ChecksumList")] + /// Path to the aggregate checksum list. + checksum_list: String, +} + +#[derive(Debug, Serialize)] +/// Summary printed after running the Docker container smoke test. +struct DockerSmokeSummary { + #[serde(rename = "Dockerfile")] + /// Dockerfile used to build the image. + dockerfile: String, + #[serde(rename = "ComposeFile")] + /// Compose file shipped with the image. + compose_file: String, + #[serde(rename = "VersionOutput")] + /// Captured `aria2c --version` output. + version_output: String, + #[serde(rename = "RuntimeConfigPreview")] + /// Captured generated runtime config. + runtime_config_preview: String, + #[serde(rename = "RpcResponsePreview")] + /// Captured JSON-RPC probe response. + rpc_response_preview: String, +} + +#[derive(Debug, Serialize)] +/// Summary printed after running the shell-only Docker entrypoint smoke. +struct DockerSmokeLocalSummary { + #[serde(rename = "Entrypoint")] + /// Entrypoint script exercised by the smoke. + entrypoint: String, + #[serde(rename = "RuntimeConfig")] + /// Generated runtime config path. + runtime_config: String, + #[serde(rename = "BaseConfig")] + /// Materialized base config path. + base_config: String, + #[serde(rename = "CapturedArgv")] + /// Arguments captured by the fake aria2 binary. + captured_argv: Vec, +} + +/// Exports a local Docker image archive and checksum manifest. +pub fn run_export_local(args: &ExportLocalArgs) -> Result<()> { + let metadata = load_workspace_metadata()?; + let repo_root = repo_root(); + let dockerfile = dockerfile_path(&repo_root); + let output_root = args + .output_root + .clone() + .unwrap_or_else(|| repo_root.join("dist").join("docker")); + + if args.build { + build_image(&args.tag, &dockerfile, &repo_root)?; + } + + assert_image_available(&args.tag)?; + + let version_dir = output_root.join(format!("v{}", metadata.package_version)); + std::fs::create_dir_all(&version_dir) + .with_context(|| format!("failed to create {}", version_dir.display()))?; + + let archive_base_name = format!("aria2-rust-pro-docker-v{}", metadata.package_version); + let archive_name = format!("{archive_base_name}.tar"); + let archive_path = version_dir.join(&archive_name); + if archive_path.exists() { + std::fs::remove_file(&archive_path) + .with_context(|| format!("failed to remove {}", archive_path.display()))?; + } + + run_command( + "docker", + &[ + os("save"), + os("-o"), + archive_path.clone().into_os_string(), + os(&args.tag), + ], + )?; + + let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?; + let archive_hash = sha256_hex(&archive_path)?; + let image_id = capture_command_stdout( + "docker", + &[ + os("image"), + os("inspect"), + os("--format"), + os("{{.Id}}"), + os(&args.tag), + ], + )?; + let archive_path_text = path_display_string(&archive_path); + let checksum_list_path = path_display_string(&checksum_list); + let manifest_path = version_dir.join(format!("{archive_base_name}.manifest.json")); + let manifest_path_text = path_display_string(&manifest_path); + let manifest = DockerArchiveManifest { + version: metadata.package_version.clone(), + tag: args.tag.clone(), + image_id: trim_owned(&image_id), + archive: DockerArchiveArtifact { + file_name: archive_name, + path: archive_path_text.clone(), + sha256: archive_hash, + }, + checksum_list: checksum_list_path.clone(), + generated_at_utc: utc_now_rfc3339()?, + }; + let manifest_text = serde_json::to_string_pretty(&manifest) + .context("failed to serialize docker export manifest")?; + write_utf8_text(&manifest_path, &(manifest_text + "\n"))?; + + let summary = DockerExportSummary { + version: metadata.package_version, + tag: args.tag.clone(), + archive_path: archive_path_text, + manifest_path: manifest_path_text, + checksum_list: checksum_list_path, + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize docker export summary")? + ); + Ok(()) +} + +/// Runs the Docker image smoke workflow against a live container. +pub fn run_smoke(args: &DockerSmokeArgs) -> Result<()> { + let repo_root = repo_root(); + let dockerfile = dockerfile_path(&repo_root); + let compose_file = compose_file_path(&repo_root); + + if args.build { + build_image(&args.tag, &dockerfile, &repo_root)?; + } + + let version_output = capture_command_stdout( + "docker", + &[ + os("run"), + os("--rm"), + os(&args.tag), + os("aria2c"), + os("--version"), + ], + )?; + + let container_id = start_smoke_container(args)?; + let _guard = DockerContainerGuard::new(container_id.clone()); + + sleep(Duration::from_secs(3)); + docker_exec_ok(&container_id, &["test", "-f", "/config/aria2.conf"]) + .context("container did not materialize /config/aria2.conf")?; + + let runtime_config = capture_command_stdout( + "docker", + &[ + os("exec"), + os(&container_id), + os("cat"), + os("/run/aria2-rust-pro/aria2.generated.conf"), + ], + ) + .context("docker exec runtime config read failed")?; + + verify_container_special_mode(&container_id, &runtime_config, &args.special_mode)?; + + let rpc_payload = format!( + "{{\"jsonrpc\":\"2.0\",\"id\":\"smoke\",\"method\":\"aria2.getVersion\",\"params\":[\"token:{}\"]}}", + args.rpc_secret + ); + let rpc_response = capture_command_stdout_with_stdin( + "docker", + &[ + os("exec"), + os("-i"), + os(&container_id), + os("curl"), + os("-fsS"), + os("-H"), + os("Content-Type: application/json"), + os("--data-binary"), + os("@-"), + os("http://127.0.0.1:6800/jsonrpc"), + ], + &rpc_payload, + ) + .context("docker exec rpc probe failed")?; + + ensure_contains( + &rpc_response, + "\"version\"", + "rpc probe did not return version payload", + )?; + verify_container_runtime_config(&runtime_config)?; + let dockerfile_path = path_display_string(&dockerfile); + let compose_file_path = path_display_string(&compose_file); + + let summary = DockerSmokeSummary { + dockerfile: dockerfile_path, + compose_file: compose_file_path, + version_output: trim_owned(&version_output), + runtime_config_preview: trim_owned(&redact_runtime_config(&runtime_config)), + rpc_response_preview: trim_owned(&rpc_response), + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize docker smoke summary")? + ); + Ok(()) +} + +/// Starts the container used by the Docker smoke test and returns its id. +fn start_smoke_container(args: &DockerSmokeArgs) -> Result { + let port_mapping = format!("{}:6800", args.host_rpc_port); + let container_id = capture_command_stdout( + "docker", + &[ + os("run"), + os("-d"), + os("-e"), + os(&format!("RPC_SECRET={}", args.rpc_secret)), + os("-e"), + os("UPDATE_TRACKERS=false"), + os("-e"), + os(&format!("SPECIAL_MODE={}", args.special_mode)), + os("-p"), + os(&port_mapping), + os(&args.tag), + ], + )?; + let container_id = trim_owned(&container_id); + if container_id.is_empty() { + bail!("docker run -d returned an empty container id"); + } + Ok(container_id) +} + +/// Verifies special-mode side effects inside the smoke-test container. +fn verify_container_special_mode( + container_id: &str, + runtime_config: &str, + special_mode: &str, +) -> Result<()> { + if special_mode == "move" { + docker_exec_ok(container_id, &["test", "-f", "/config/script/move.sh"]) + .context("container did not materialize move special-mode script")?; + ensure_contains( + runtime_config, + "on-download-complete=/config/script/move.sh", + "runtime config did not apply SPECIAL_MODE=move hook override", + )?; + } + Ok(()) +} + +/// Verifies common generated runtime config snippets for the container smoke. +fn verify_container_runtime_config(runtime_config: &str) -> Result<()> { + ensure_contains( + runtime_config, + "bt-tracker=", + "runtime config did not seed bundled bt-tracker snapshot", + ) +} + +/// Runs the Docker entrypoint smoke locally with a fake aria2 binary. +pub fn run_smoke_local(_args: &DockerSmokeLocalArgs) -> Result<()> { + let workspace = SmokeLocalWorkspace::new()?; + let shell = find_posix_shell() + .ok_or_else(|| anyhow!("no POSIX shell was found for docker smoke-local"))?; + + run_shell_script(&shell, SMOKE_LOCAL_SCRIPT, &workspace.envs)?; + + let downloads_dir_unix = capture_unix_path(&shell, "DOWNLOAD_DIR_WIN", &workspace.envs)?; + let config_dir_unix = capture_unix_path(&shell, "CONFIG_DIR_WIN", &workspace.envs)?; + + let runtime_config = workspace.runtime_dir.join("aria2.generated.conf"); + validate_smoke_local_outputs(&runtime_config, &workspace.capture_path)?; + let runtime_config_text = std::fs::read_to_string(&runtime_config) + .with_context(|| format!("failed to read {}", runtime_config.display()))?; + let captured_argv_text = std::fs::read_to_string(&workspace.capture_path) + .with_context(|| format!("failed to read {}", workspace.capture_path.display()))?; + let captured_argv = captured_argv_text + .lines() + .map(str::to_owned) + .collect::>(); + + let base_config = workspace.config_dir.join("aria2.conf"); + let session_file = workspace.config_dir.join("aria2.session"); + validate_smoke_local_files(&workspace.config_dir, &base_config, &session_file)?; + validate_smoke_local_config(&runtime_config_text, &downloads_dir_unix, &config_dir_unix)?; + validate_smoke_local_argv(&captured_argv)?; + let entrypoint_path = path_display_string(&workspace.entrypoint); + let runtime_config_path = path_display_string(&runtime_config); + let base_config_path = path_display_string(&base_config); + + let summary = DockerSmokeLocalSummary { + entrypoint: entrypoint_path, + runtime_config: runtime_config_path, + base_config: base_config_path, + captured_argv, + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize docker smoke-local summary")? + ); + Ok(()) +} + +/// Temporary workspace for the local Docker entrypoint smoke. +struct SmokeLocalWorkspace { + /// Temporary directory whose lifetime owns all smoke files. + _work_root: tempfile::TempDir, + /// Entrypoint script under test. + entrypoint: PathBuf, + /// Temporary configuration directory. + config_dir: PathBuf, + /// Temporary runtime directory. + runtime_dir: PathBuf, + /// File where the fake aria2 binary records argv. + capture_path: PathBuf, + /// Environment variables passed to the smoke shell. + envs: Vec<(&'static str, OsString)>, +} + +impl SmokeLocalWorkspace { + /// Creates the temporary workspace and fake aria2 executable. + fn new() -> Result { + let repo_root = repo_root(); + let entrypoint = repo_root.join("docker").join("entrypoint.sh"); + let defaults_dir = repo_root.join("docker").join("defaults"); + let work_root = + tempfile::tempdir().context("failed to create temporary smoke-local root")?; + let config_dir = work_root.path().join("config"); + let downloads_dir = work_root.path().join("downloads"); + let runtime_dir = work_root.path().join("run"); + let bin_dir = work_root.path().join("bin"); + for directory in [&config_dir, &downloads_dir, &runtime_dir, &bin_dir] { + std::fs::create_dir_all(directory) + .with_context(|| format!("failed to create {}", directory.display()))?; + } + + let capture_path = work_root.path().join("captured-argv.txt"); + let fake_aria2 = bin_dir.join("aria2c"); + write_utf8_text( + &fake_aria2, + "#!/usr/bin/env bash\nset -eu\nprintf '%s\\n' \"$@\" > \"$CAPTURE_PATH\"\n", + )?; + let envs = smoke_local_envs( + &entrypoint, + &defaults_dir, + &config_dir, + &downloads_dir, + &runtime_dir, + &fake_aria2, + &capture_path, + ); + Ok(Self { + _work_root: work_root, + entrypoint, + config_dir, + runtime_dir, + capture_path, + envs, + }) + } +} + +/// Verifies that the smoke script wrote its primary outputs. +fn validate_smoke_local_outputs(runtime_config: &Path, capture_path: &Path) -> Result<()> { + if !runtime_config.is_file() { + bail!("entrypoint did not generate runtime config"); + } + if !capture_path.is_file() { + bail!("fake aria2 binary did not capture argv"); + } + Ok(()) +} + +/// Verifies files materialized by the local Docker entrypoint smoke. +fn validate_smoke_local_files( + config_dir: &Path, + base_config: &Path, + session_file: &Path, +) -> Result<()> { + if !base_config.is_file() { + bail!("entrypoint did not materialize base aria2.conf"); + } + if !session_file.is_file() { + bail!("entrypoint did not materialize aria2.session"); + } + let move_script = config_dir.join("script").join("move.sh"); + if !move_script.is_file() { + bail!("entrypoint did not materialize move special-mode script"); + } + Ok(()) +} + +/// Verifies expected generated runtime config snippets. +fn validate_smoke_local_config( + runtime_config_text: &str, + downloads_dir_unix: &str, + config_dir_unix: &str, +) -> Result<()> { + let session_file_unix = format!("{config_dir_unix}/aria2.session"); + for snippet in [ + format!("dir={downloads_dir_unix}"), + format!("input-file={session_file_unix}"), + format!("save-session={session_file_unix}"), + "save-session-interval=60".to_owned(), + "enable-rpc=true".to_owned(), + "rpc-listen-port=16800".to_owned(), + "listen-port=51413".to_owned(), + "dht-listen-port=51413".to_owned(), + "disable-ipv6=false".to_owned(), + "rpc-secret=smoke-secret".to_owned(), + "rpc-listen-all=true".to_owned(), + "disk-cache=32M".to_owned(), + format!("on-download-complete={config_dir_unix}/script/move.sh"), + ] { + ensure_contains( + runtime_config_text, + &snippet, + "runtime config missing expected snippet", + )?; + } + ensure_contains( + runtime_config_text, + "bt-tracker=", + "runtime config did not seed bundled bt-tracker snapshot", + ) +} + +/// Verifies the fake aria2 binary observed the expected argument flow. +fn validate_smoke_local_argv(captured_argv: &[String]) -> Result<()> { + if captured_argv.len() < 3 { + bail!("captured argv is incomplete: {captured_argv:?}"); + } + if captured_argv + .first() + .is_none_or(|value| value != "--enable-rpc") + { + bail!("captured argv missing --enable-rpc bootstrap: {captured_argv:?}"); + } + if captured_argv + .get(1) + .is_none_or(|value| !value.starts_with("--conf-path=")) + { + bail!("captured argv missing conf-path: {captured_argv:?}"); + } + if captured_argv + .last() + .is_none_or(|value| value != "--version") + { + bail!("captured argv did not preserve passthrough args: {captured_argv:?}"); + } + Ok(()) +} + +/// POSIX shell script used by the local Docker entrypoint smoke. +const SMOKE_LOCAL_SCRIPT: &str = r#" +set -eu +to_unix_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" + else + printf '%s\n' "$1" + fi +} +entrypoint=$(to_unix_path "$ENTRYPOINT_WIN") +defaults_dir=$(to_unix_path "$DEFAULTS_DIR_WIN") +config_dir=$(to_unix_path "$CONFIG_DIR_WIN") +downloads_dir=$(to_unix_path "$DOWNLOAD_DIR_WIN") +runtime_dir=$(to_unix_path "$RUNTIME_DIR_WIN") +fake_aria2=$(to_unix_path "$FAKE_ARIA2_WIN") +capture_path=$(to_unix_path "$CAPTURE_PATH_WIN") +chmod +x "$fake_aria2" +CONFIG_DIR="$config_dir" \ +DOWNLOAD_DIR="$downloads_dir" \ +RUNTIME_DIR="$runtime_dir" \ +DEFAULTS_DIR="$defaults_dir" \ +ARIA2_BIN="$fake_aria2" \ +CAPTURE_PATH="$capture_path" \ +RPC_SECRET='smoke-secret' \ +RPC_PORT='16800' \ +DISK_CACHE='32M' \ +LISTEN_PORT='51413' \ +IPV6_MODE='true' \ +UPDATE_TRACKERS='false' \ +CUSTOM_TRACKER_URL='https://example.invalid/trackers.txt' \ +SPECIAL_MODE='move' \ +UMASK_SET='027' \ +"$entrypoint" --version +"#; + +/// Drop guard that force-removes a smoke-test container. +struct DockerContainerGuard { + /// Container id returned by `docker run -d`. + container_id: String, +} + +impl DockerContainerGuard { + /// Builds a container cleanup guard. + const fn new(container_id: String) -> Self { + Self { container_id } + } +} + +impl Drop for DockerContainerGuard { + fn drop(&mut self) { + let _ = Command::new("docker") + .args(["rm", "-f", &self.container_id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +/// Returns the repository Dockerfile path. +fn dockerfile_path(repo_root: &Path) -> PathBuf { + repo_root.join("docker").join("Dockerfile") +} + +/// Returns the repository Docker Compose file path. +fn compose_file_path(repo_root: &Path) -> PathBuf { + repo_root.join("docker").join("docker-compose.yml") +} + +/// Builds the Docker image used by export and smoke workflows. +fn build_image(tag: &str, dockerfile: &Path, repo_root: &Path) -> Result<()> { + let envs = if env::var_os("DOCKER_BUILDKIT").is_none() { + // Synology Docker 24 can leave buildx sessions idle on this full release build. + vec![("DOCKER_BUILDKIT", os("0"))] + } else { + Vec::new() + }; + run_command_with_env( + "docker", + &[ + os("build"), + os("-t"), + os(tag), + os("-f"), + dockerfile.into(), + repo_root.into(), + ], + &envs, + None, + ) +} + +/// Ensures the requested Docker image tag exists locally. +fn assert_image_available(tag: &str) -> Result<()> { + run_command("docker", &[os("image"), os("inspect"), os(tag)]).map_err(|_| { + anyhow!("docker image `{tag}` is not available; build it first or pass --build") + }) +} + +/// Runs a Docker exec command that is expected to succeed. +fn docker_exec_ok(container_id: &str, tail: &[&str]) -> Result<()> { + let mut args = vec![os("exec"), os(container_id)]; + args.extend(tail.iter().map(|value| os(value))); + run_command("docker", &args) +} + +/// Runs a command and captures UTF-8 stdout. +fn capture_command_stdout(program: &str, args: &[OsString]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to launch `{program}`"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "`{program}` failed with status {}: {}", + render_exit_status(output.status), + stderr.trim() + ); + } + String::from_utf8(output.stdout).context("command emitted non-UTF-8 stdout") +} + +/// Runs a command with UTF-8 stdin and captures UTF-8 stdout. +fn capture_command_stdout_with_stdin( + program: &str, + args: &[OsString], + stdin_text: &str, +) -> Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("failed to launch `{program}`"))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("failed to open stdin for `{program}`"))?; + stdin + .write_all(stdin_text.as_bytes()) + .with_context(|| format!("failed to write stdin for `{program}`"))?; + drop(stdin); + let output = child + .wait_with_output() + .with_context(|| format!("failed to wait for `{program}`"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "`{program}` failed with status {}: {}", + render_exit_status(output.status), + stderr.trim() + ); + } + String::from_utf8(output.stdout).context("command emitted non-UTF-8 stdout") +} + +/// Builds Windows-path environment variables consumed by the local smoke script. +fn smoke_local_envs( + entrypoint: &Path, + defaults_dir: &Path, + config_dir: &Path, + downloads_dir: &Path, + runtime_dir: &Path, + fake_aria2: &Path, + capture_path: &Path, +) -> Vec<(&'static str, OsString)> { + vec![ + ("ENTRYPOINT_WIN", entrypoint.as_os_str().to_os_string()), + ("DEFAULTS_DIR_WIN", defaults_dir.as_os_str().to_os_string()), + ("CONFIG_DIR_WIN", config_dir.as_os_str().to_os_string()), + ("DOWNLOAD_DIR_WIN", downloads_dir.as_os_str().to_os_string()), + ("RUNTIME_DIR_WIN", runtime_dir.as_os_str().to_os_string()), + ("FAKE_ARIA2_WIN", fake_aria2.as_os_str().to_os_string()), + ("CAPTURE_PATH_WIN", capture_path.as_os_str().to_os_string()), + ] +} + +/// Captures a workspace path converted for the selected POSIX shell. +fn capture_unix_path( + shell: &Path, + env_name: &'static str, + envs: &[(&str, OsString)], +) -> Result { + let script = format!( + r#" +set -eu +to_unix_path() {{ + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" + else + printf '%s\n' "$1" + fi +}} +to_unix_path "${{{env_name}}}" +"# + ); + Ok(capture_shell_stdout(shell, &script, envs)? + .trim() + .to_string()) +} + +/// Runs a POSIX shell script with the provided environment variables. +fn run_shell_script(shell: &Path, script: &str, envs: &[(&str, OsString)]) -> Result<()> { + let mut command = Command::new(shell); + command.arg("-lc").arg(script); + for (key, value) in envs { + command.env(key, value); + } + let status = command + .status() + .with_context(|| format!("failed to launch {}", shell.display()))?; + if status.success() { + Ok(()) + } else { + bail!( + "{} failed with status {}", + shell.display(), + render_exit_status(status) + ) + } +} + +/// Runs a POSIX shell script and captures UTF-8 stdout. +fn capture_shell_stdout(shell: &Path, script: &str, envs: &[(&str, OsString)]) -> Result { + let mut command = Command::new(shell); + command.arg("-lc").arg(script); + for (key, value) in envs { + command.env(key, value); + } + let output = command + .output() + .with_context(|| format!("failed to launch {}", shell.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "{} failed with status {}: {}", + shell.display(), + render_exit_status(output.status), + stderr.trim() + ); + } + String::from_utf8(output.stdout).context("shell emitted non-UTF-8 stdout") +} + +/// Locates a POSIX shell suitable for the local Docker entrypoint smoke. +fn find_posix_shell() -> Option { + let names: &[&str] = if cfg!(windows) { + &["bash.exe", "bash", "sh.exe", "sh"] + } else { + &["bash", "sh"] + }; + let mut candidates = Vec::new(); + if let Some(path_var) = env::var_os("PATH") { + for directory in env::split_paths(&path_var) { + for name in names { + let candidate = directory.join(name); + if candidate.is_file() { + candidates.push(candidate); + } + } + } + } + + if cfg!(windows) { + for candidate in [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + r"C:\Program Files\Git\bin\sh.exe", + r"C:\Program Files\Git\usr\bin\sh.exe", + ] { + let path = PathBuf::from(candidate); + if path.is_file() { + candidates.push(path); + } + } + } + + choose_posix_shell_candidate(&candidates) +} + +/// Chooses the best available POSIX shell candidate for local smoke checks. +fn choose_posix_shell_candidate(candidates: &[PathBuf]) -> Option { + candidates + .iter() + .find(|candidate| !is_windowsapps_bash(candidate)) + .cloned() + .or_else(|| candidates.first().cloned()) +} + +/// Returns whether the shell path resolves to the `WindowsApps` WSL launcher. +fn is_windowsapps_bash(path: &Path) -> bool { + let normalized = path + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + normalized.contains("\\windowsapps\\bash.exe") +} + +/// Ensures a captured text payload contains an expected snippet. +fn ensure_contains(text: &str, needle: &str, context: &str) -> Result<()> { + if text.contains(needle) { + Ok(()) + } else { + bail!("{context}: {needle}"); + } +} + +/// Writes per-artifact and aggregate SHA-256 checksum files. +fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result { + let mut archive_candidates = std::fs::read_dir(output_directory) + .with_context(|| format!("failed to read {}", output_directory.display()))? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file() && is_archive_path(path)) + .collect::>(); + archive_candidates.sort(); + + for path in paths { + let hash = sha256_hex(path)?; + let file_name = file_name_string_lossy(path)?; + let line = format!("{hash} *{file_name}\n"); + write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?; + } + + let mut lines = Vec::with_capacity(archive_candidates.len()); + for path in archive_candidates { + let hash = sha256_hex(&path)?; + let file_name = file_name_string_lossy(&path)?; + lines.push(format!("{hash} *{file_name}")); + } + + let checksum_list_path = output_directory.join("SHA256SUMS.txt"); + write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?; + Ok(checksum_list_path) +} + +/// Computes a hex-encoded SHA-256 digest for a file. +fn sha256_hex(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = Box::new([0_u8; 64 * 1024]); + loop { + let read = file + .read(&mut buffer[..]) + .with_context(|| format!("failed to read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// Returns whether a path looks like a Docker archive. +fn is_archive_path(path: &Path) -> bool { + let file_name = path + .file_name() + .map(OsStr::to_string_lossy) + .unwrap_or_default(); + file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar") +} + +/// Trims surrounding whitespace and returns an owned string. +fn trim_owned(text: &str) -> String { + text.trim().to_owned() +} + +/// Redacts generated runtime config lines that may carry local secrets. +fn redact_runtime_config(text: &str) -> String { + text.lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with("rpc-secret=") { + let indent = line.strip_suffix(trimmed).unwrap_or_default(); + format!("{indent}rpc-secret=") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n") +} + +/// Renders a process exit status for diagnostics. +fn render_exit_status(status: ExitStatus) -> String { + status + .code() + .map_or_else(|| String::from("signal"), |code| code.to_string()) +} + +/// Returns the trailing file name as UTF-8 text for archive reporting. +fn file_name_string_lossy(path: &Path) -> Result { + path.file_name() + .ok_or_else(|| { + anyhow!( + "archive path did not include a file name: {}", + path.display() + ) + }) + .map(|name| OsStr::to_string_lossy(name).into_owned()) +} + +/// Renders a path display adapter into an owned string. +fn path_display_string(path: &Path) -> String { + format!("{}", path.display()) +} + +/// Converts a string slice into an owned OS string for command arguments. +fn os(value: &str) -> OsString { + OsString::from(value) +} + +#[cfg(test)] +mod tests { + use super::{choose_posix_shell_candidate, redact_runtime_config}; + use std::path::PathBuf; + + #[test] + fn prefers_git_bash_over_windowsapps_bash_on_windows() { + let candidates = vec![ + PathBuf::from(r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe"), + PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"), + ]; + + let selected = choose_posix_shell_candidate(&candidates); + + assert_eq!( + selected, + Some(PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")) + ); + } + + #[test] + fn falls_back_to_windowsapps_bash_when_no_better_shell_exists() { + let candidates = vec![PathBuf::from( + r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe", + )]; + + let selected = choose_posix_shell_candidate(&candidates); + + assert_eq!( + selected, + Some(PathBuf::from( + r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe", + )) + ); + } + + #[test] + fn redacts_runtime_config_rpc_secret_preview() { + let config = "enable-rpc=true\nrpc-secret=super-secret\n rpc-secret=indented\n"; + + let redacted = redact_runtime_config(config); + + assert_eq!( + redacted, + "enable-rpc=true\nrpc-secret=\n rpc-secret=" + ); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..84bbaa3 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,68 @@ +//! Cargo-native workflow entrypoints for `aria2-rust-pro`. +#![expect( + unreachable_pub, + clippy::indexing_slicing, + clippy::integer_division, + clippy::large_stack_arrays, + clippy::redundant_pub_crate, + clippy::too_many_lines, + reason = "xtask is an internal workflow binary; product-facing runtime crates keep the stricter public-surface and implementation discipline" +)] + +/// `clap` command-line model for the cargo-native workflow entrypoint. +mod cli; +/// Compatibility-oriented maintenance workflows. +mod compat; +/// Local Docker export and smoke workflows. +mod docker; +/// Performance collection and same-host comparison workflows. +mod perf; +/// Release packaging and version-smoke workflows. +mod release; +/// Strict local quality-sweep workflows. +mod testing; +/// Shared workspace discovery and command helpers. +mod workspace; + +use anyhow::Result; +use clap::Parser; + +use crate::cli::{ + Cli, CompatSubcommand, DockerSubcommand, PerfSubcommand, ReleaseSubcommand, TestingSubcommand, + TopLevelCommand, +}; + +/// Runs the `xtask` entrypoint and converts structured failures into a non-zero exit code. +fn main() { + if let Err(error) = run() { + eprintln!("{error:#}"); + std::process::exit(1); + } +} + +/// Dispatches the requested top-level `xtask` workflow. +fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + TopLevelCommand::Compat(command) => match command.command { + CompatSubcommand::CaptureCliGoldens(args) => compat::run_capture_cli_goldens(&args), + }, + TopLevelCommand::Docker(command) => match command.command { + DockerSubcommand::ExportLocal(args) => docker::run_export_local(&args), + DockerSubcommand::Smoke(args) => docker::run_smoke(&args), + DockerSubcommand::SmokeLocal(args) => docker::run_smoke_local(&args), + }, + TopLevelCommand::Perf(command) => match command.command { + PerfSubcommand::CollectLocalComparison(args) => { + perf::run_collect_local_comparison(&args) + } + }, + TopLevelCommand::Release(command) => match command.command { + ReleaseSubcommand::SmokeVersion(args) => release::run_release_smoke_version(&args), + ReleaseSubcommand::PackageLocal(args) => release::run_package_local(&args), + }, + TopLevelCommand::Testing(command) => match command.command { + TestingSubcommand::StrictSweep(args) => testing::run_strict_sweep(&args), + }, + } +} diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs new file mode 100644 index 0000000..7f43514 --- /dev/null +++ b/xtask/src/perf.rs @@ -0,0 +1,1620 @@ +//! Performance-oriented Cargo-native workflow entrypoints. + +use std::{ + collections::HashSet, + ffi::{OsStr, OsString}, + fs::File, + io::{BufRead, BufReader, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, ExitStatus, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use time::{OffsetDateTime, UtcOffset, format_description::parse}; +use walkdir::WalkDir; + +use crate::{ + cli::CollectLocalComparisonArgs, + workspace::{repo_root, root_manifest_path, run_command, write_utf8_text}, +}; + +#[doc(hidden)] +#[derive(Debug)] +struct BenchMetrics { + #[doc(hidden)] + peak_working_set_bytes: Option, + #[doc(hidden)] + peak_working_set_mib: Option, + #[doc(hidden)] + peak_handle_count: Option, + #[doc(hidden)] + exit_code: Option, +} + +#[doc(hidden)] +#[derive(Debug)] +struct CriterionSummaryRow { + #[doc(hidden)] + key: String, + #[doc(hidden)] + mean: String, + #[doc(hidden)] + mean_lower: String, + #[doc(hidden)] + mean_upper: String, +} + +#[doc(hidden)] +#[derive(Debug)] +struct ReferenceArtifactDetection { + #[doc(hidden)] + rust_cli_executable: Option, + #[doc(hidden)] + upstream_windows_executable: Option, + #[doc(hidden)] + upstream_version_summary: Option, + #[doc(hidden)] + pro_core_artifact: Option, + #[doc(hidden)] + pro_core_version_summary: Option, +} + +#[doc(hidden)] +#[derive(Debug)] +struct SameHostLocalHttpComparison { + #[doc(hidden)] + payload_bytes: usize, + #[doc(hidden)] + payload_sha256: String, + #[doc(hidden)] + scenarios: Vec, +} + +#[doc(hidden)] +#[derive(Debug)] +struct ComparisonScenarioResult { + #[doc(hidden)] + label: &'static str, + #[doc(hidden)] + description: &'static str, + #[doc(hidden)] + rust: TimedTransferSummary, + #[doc(hidden)] + pro_core: TimedTransferSummary, + #[doc(hidden)] + upstream: TimedTransferSummary, +} + +#[doc(hidden)] +#[derive(Debug)] +struct TimedTransferSummary { + #[doc(hidden)] + exit_code: Option, + #[doc(hidden)] + elapsed: Duration, + #[doc(hidden)] + fastest: Duration, + #[doc(hidden)] + slowest: Duration, + #[doc(hidden)] + sample_count: usize, + #[doc(hidden)] + timed_out: bool, + #[doc(hidden)] + output_count: usize, + #[doc(hidden)] + output_bytes: u64, + #[doc(hidden)] + all_hashes_match: bool, +} + +#[doc(hidden)] +#[derive(Debug)] +struct TimedTransferObservation { + #[doc(hidden)] + exit_code: Option, + #[doc(hidden)] + elapsed: Duration, + #[doc(hidden)] + timed_out: bool, + #[doc(hidden)] + output_count: usize, + #[doc(hidden)] + output_bytes: u64, + #[doc(hidden)] + all_hashes_match: bool, +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +struct ScenarioSpec { + #[doc(hidden)] + label: &'static str, + #[doc(hidden)] + description: &'static str, + #[doc(hidden)] + split: usize, + #[doc(hidden)] + max_connection_per_server: usize, + #[doc(hidden)] + route_paths: &'static [&'static str], + #[doc(hidden)] + expected_outputs: &'static [&'static str], +} + +#[doc(hidden)] +pub(crate) fn run_collect_local_comparison(args: &CollectLocalComparisonArgs) -> Result<()> { + let repo_root = repo_root(); + let target_root = std::env::var_os("CARGO_TARGET_DIR") + .map_or_else(|| repo_root.join("target"), PathBuf::from); + let criterion_root = target_root.join("criterion"); + let bench_deps_root = target_root.join("release").join("deps"); + let manifest_path = root_manifest_path(); + let output_path = args.output_path.clone().unwrap_or_else(|| { + repo_root + .join("docs") + .join("perf") + .join("local-comparison.md") + }); + + let bench_executable = ensure_bench_executable(&manifest_path, &bench_deps_root)?; + let metrics = if args.skip_bench_execution { + BenchMetrics { + peak_working_set_bytes: None, + peak_working_set_mib: None, + peak_handle_count: None, + exit_code: None, + } + } else { + invoke_bench_with_metrics( + &bench_executable, + args.sample_size, + args.measurement_seconds, + args.warmup_seconds, + )? + }; + let rows = collect_criterion_summaries(&criterion_root)?; + let references = detect_reference_artifacts(&target_root); + let same_host_comparison = match ( + references.rust_cli_executable.as_ref(), + references.pro_core_artifact.as_ref(), + references.upstream_windows_executable.as_ref(), + ) { + (Some(rust), Some(pro), Some(upstream)) => Some(invoke_same_host_local_http_comparison( + rust, + pro, + upstream, + args.transfer_bytes, + args.same_host_runs, + )?), + _ => None, + }; + let report = build_report( + &repo_root, + &bench_executable, + &manifest_path, + &metrics, + &references, + same_host_comparison.as_ref(), + &rows, + )?; + write_utf8_text(&output_path, &report)?; + println!("{}", output_path.display()); + Ok(()) +} + +#[doc(hidden)] +fn ensure_bench_executable(manifest_path: &Path, bench_deps_root: &Path) -> Result { + if let Some(path) = find_bench_executable(bench_deps_root)? { + return Ok(path); + } + + run_command( + "cargo", + &[ + os("bench"), + os("--manifest-path"), + manifest_path.into(), + os("-p"), + os("aria2-rust-pro-tests"), + os("--bench"), + os("rpc_pressure"), + os("--no-run"), + ], + )?; + + find_bench_executable(bench_deps_root)?.ok_or_else(|| { + anyhow!( + "rpc_pressure benchmark executable was not found under {}", + bench_deps_root.display() + ) + }) +} + +#[doc(hidden)] +fn find_bench_executable(bench_deps_root: &Path) -> Result> { + if !bench_deps_root.is_dir() { + return Ok(None); + } + let mut candidates = std::fs::read_dir(bench_deps_root) + .with_context(|| format!("failed to read {}", bench_deps_root.display()))? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rpc_pressure-")) + && is_native_executable(path) + }) + .collect::>(); + candidates.sort_by_key(|path| { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + }); + Ok(candidates.pop()) +} + +#[doc(hidden)] +fn is_native_executable(path: &Path) -> bool { + if cfg!(windows) { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) + } else { + path.extension().is_none() + } +} + +#[doc(hidden)] +fn invoke_bench_with_metrics( + executable_path: &Path, + sample_size: f64, + measurement_seconds: f64, + warmup_seconds: f64, +) -> Result { + let mut child = Command::new(executable_path) + .args([ + "--sample-size", + &sample_size.to_string(), + "--measurement-time", + &measurement_seconds.to_string(), + "--warm-up-time", + &warmup_seconds.to_string(), + ]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to launch {}", executable_path.display()))?; + + #[cfg(target_os = "windows")] + let peak_working_set_bytes = monitor_peak_working_set(child.id())?; + #[cfg(not(target_os = "windows"))] + let peak_working_set_bytes = None; + let status = child + .wait() + .with_context(|| format!("failed while waiting for {}", executable_path.display()))?; + if !status.success() { + bail!( + "benchmark executable failed with status {}", + render_exit_status(status) + ); + } + let exit_code = status.code(); + + Ok(BenchMetrics { + peak_working_set_bytes, + peak_working_set_mib: peak_working_set_bytes.map(format_mib_hundredths), + peak_handle_count: None, + exit_code, + }) +} + +#[doc(hidden)] +#[cfg(target_os = "windows")] +fn monitor_peak_working_set(pid: u32) -> Result> { + let mut peak = 0_u64; + loop { + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .output() + .context("failed to launch tasklist")?; + if !output.status.success() { + break; + } + let stdout = + String::from_utf8(output.stdout).context("tasklist emitted non-UTF-8 stdout")?; + if !stdout.contains(&format!(",\"{pid}\",")) { + break; + } + let line = stdout.lines().next().unwrap_or_default(); + let cells = parse_csv_line(line); + if let Some(memory_cell) = cells.get(4) { + let memory_text = memory_cell + .replace(',', "") + .replace(" K", "") + .replace(" K*", "") + .trim() + .to_owned(); + if let Ok(kib) = memory_text.parse::() { + peak = peak.max(kib.saturating_mul(1024)); + } + } + thread::sleep(Duration::from_millis(50)); + } + Ok(Some(peak)) +} + +#[cfg(target_os = "windows")] +#[doc(hidden)] +fn parse_csv_line(line: &str) -> Vec { + let mut cells = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + for ch in line.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + cells.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + cells.push(current); + cells +} + +#[doc(hidden)] +fn collect_criterion_summaries(criterion_root: &Path) -> Result> { + if !criterion_root.is_dir() { + return Ok(Vec::new()); + } + + let mut rows = Vec::new(); + for entry in WalkDir::new(criterion_root) + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.file_type().is_file() && entry.file_name() == "estimates.json") + { + let path = entry.path(); + let path_text = path.to_string_lossy().replace('\\', "/"); + if !path_text.contains("/new/estimates.json") { + continue; + } + let relative = path.strip_prefix(criterion_root).with_context(|| { + format!( + "failed to strip {} from {}", + criterion_root.display(), + path.display() + ) + })?; + let key = relative + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches("/new/estimates.json") + .to_owned(); + let estimate = read_estimate_summary(path)?; + rows.push(CriterionSummaryRow { + key, + mean: format_estimate_ns(&estimate.point_estimate), + mean_lower: format_estimate_ns(&estimate.confidence_lower), + mean_upper: format_estimate_ns(&estimate.confidence_upper), + }); + } + rows.sort_by(|left, right| left.key.cmp(&right.key)); + Ok(rows) +} + +#[doc(hidden)] +#[derive(Debug)] +struct EstimateSummary { + #[doc(hidden)] + point_estimate: DecimalValue, + #[doc(hidden)] + confidence_lower: DecimalValue, + #[doc(hidden)] + confidence_upper: DecimalValue, +} + +#[doc(hidden)] +#[derive(Debug)] +struct DecimalValue { + #[doc(hidden)] + significand: u128, + #[doc(hidden)] + scale: u32, +} + +impl DecimalValue { + #[doc(hidden)] + const fn is_at_least(&self, threshold: u128) -> bool { + self.significand >= threshold.saturating_mul(10_u128.saturating_pow(self.scale)) + } + + #[expect( + clippy::integer_division, + reason = "fixed-point formatting intentionally rounds with integer arithmetic to avoid float drift in internal reports" + )] + #[doc(hidden)] + fn format_scaled(&self, divisor: u128, decimal_places: u32) -> String { + let denominator = divisor.saturating_mul(10_u128.saturating_pow(self.scale)); + let scale_factor = 10_u128.saturating_pow(decimal_places); + let numerator = self.significand.saturating_mul(scale_factor); + let rounded = numerator + .saturating_add(denominator / 2) + .checked_div(denominator) + .unwrap_or_default(); + format_fixed_decimal(rounded, decimal_places) + } +} + +#[expect( + clippy::arithmetic_side_effects, + clippy::integer_division, + reason = "fixed-point string rendering intentionally uses bounded integer math inside internal performance tooling" +)] +#[doc(hidden)] +fn format_fixed_decimal(value: u128, decimal_places: u32) -> String { + if decimal_places == 0 { + return value.to_string(); + } + + let scale_factor = 10_u128.saturating_pow(decimal_places); + let whole = value / scale_factor; + let fraction = value % scale_factor; + let width = usize::try_from(decimal_places).unwrap_or(0); + format!("{whole}.{fraction:0width$}") +} + +#[doc(hidden)] +fn read_decimal_value( + json: &Value, + path_segments: &[&str], + label: &str, + path: &Path, +) -> Result { + let value = path_segments + .iter() + .try_fold(json, |current, segment| current.get(*segment)) + .ok_or_else(|| anyhow!("missing {label} in {}", path.display()))?; + let number = value + .as_number() + .ok_or_else(|| anyhow!("{label} in {} is not numeric", path.display()))?; + parse_decimal_value(&number.to_string()) + .with_context(|| format!("failed to parse {label} in {}", path.display())) +} + +#[doc(hidden)] +fn parse_decimal_value(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.starts_with('-') { + bail!("negative decimal values are unsupported: {trimmed}"); + } + + let unsigned = trimmed.strip_prefix('+').unwrap_or(trimmed); + let (mantissa, exponent_text) = unsigned + .split_once(['e', 'E']) + .map_or((unsigned, None), |(base, exponent)| (base, Some(exponent))); + let exponent = exponent_text + .map(str::parse::) + .transpose() + .with_context(|| format!("invalid decimal exponent: {value}"))? + .unwrap_or_default(); + let (whole, fractional) = mantissa + .split_once('.') + .map_or((mantissa, ""), |parts| parts); + let mut digits = format!("{whole}{fractional}"); + if digits.is_empty() { + bail!("missing decimal digits: {value}"); + } + + let base_scale = i32::try_from(fractional.len()).context("decimal scale overflow")?; + let adjusted_scale = base_scale.saturating_sub(exponent); + let scale = if adjusted_scale.is_negative() { + let zero_count = usize::try_from(adjusted_scale.unsigned_abs()) + .context("decimal exponent adjustment overflow")?; + digits.push_str(&"0".repeat(zero_count)); + 0 + } else { + u32::try_from(adjusted_scale).context("decimal scale overflow")? + }; + + let significand = digits + .parse::() + .with_context(|| format!("invalid decimal digits: {value}"))?; + Ok(DecimalValue { significand, scale }) +} + +#[expect( + clippy::integer_division, + reason = "MiB summary formatting intentionally uses bounded integer math for deterministic internal reports" +)] +#[doc(hidden)] +fn format_mib_hundredths(bytes: u64) -> String { + const MEBIBYTE: u64 = 1024 * 1024; + let whole = bytes / MEBIBYTE; + let remainder = bytes % MEBIBYTE; + let hundredths = remainder.saturating_mul(100).saturating_add(MEBIBYTE / 2) / MEBIBYTE; + if hundredths == 100 { + format!("{}.00", whole.saturating_add(1)) + } else { + format!("{whole}.{hundredths:02}") + } +} + +#[doc(hidden)] +fn read_estimate_summary(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let json: Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(EstimateSummary { + point_estimate: read_decimal_value( + &json, + &["mean", "point_estimate"], + "mean.point_estimate", + path, + )?, + confidence_lower: read_decimal_value( + &json, + &["mean", "confidence_interval", "lower_bound"], + "mean.confidence_interval.lower_bound", + path, + )?, + confidence_upper: read_decimal_value( + &json, + &["mean", "confidence_interval", "upper_bound"], + "mean.confidence_interval.upper_bound", + path, + )?, + }) +} + +#[doc(hidden)] +fn format_estimate_ns(nanoseconds: &DecimalValue) -> String { + if nanoseconds.is_at_least(1_000_000) { + format!("{} ms", nanoseconds.format_scaled(1_000_000, 4)) + } else if nanoseconds.is_at_least(1_000) { + format!("{} us", nanoseconds.format_scaled(1_000, 3)) + } else { + format!("{} ns", nanoseconds.format_scaled(1, 1)) + } +} + +#[doc(hidden)] +fn detect_reference_artifacts(target_root: &Path) -> ReferenceArtifactDetection { + let mut upstream_candidates = vec![ + repo_root() + .join("dist") + .join("upstream") + .join("aria2-1.37.0-win-64bit-build1") + .join("aria2-1.37.0-win-64bit-build1") + .join("aria2c.exe"), + ]; + if let Some(parent) = repo_root().parent() { + upstream_candidates.push(parent.join("aria2").join("src").join("aria2c.exe")); + } + let upstream_windows_executable = upstream_candidates.into_iter().find(|path| path.is_file()); + let upstream_version_summary = upstream_windows_executable + .as_ref() + .and_then(|path| version_summary(path).ok()); + + let pro_core_root = repo_root() + .parent() + .map(|parent| parent.join("aria2").join("build").join("pro-core")); + let pro_core_artifact = if pro_core_root.as_ref().is_some_and(|path| path.is_dir()) { + let pro_core_root = pro_core_root.as_ref().expect("pro core root was checked"); + let mut candidates = WalkDir::new(&pro_core_root) + .into_iter() + .filter_map(std::result::Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + let path_text = path.to_string_lossy(); + path.is_file() + && path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("aria2c.exe")) + && path_text.contains("windows-x64-mingw") + && !path_text.contains("\\test") + }) + .collect::>(); + candidates.sort_by_key(|path| { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + }); + candidates.pop() + } else { + None + }; + let pro_core_version_summary = pro_core_artifact + .as_ref() + .and_then(|path| version_summary(path).ok()); + + ReferenceArtifactDetection { + rust_cli_executable: find_rust_cli_executable(target_root), + upstream_windows_executable, + upstream_version_summary, + pro_core_artifact, + pro_core_version_summary, + } +} + +#[doc(hidden)] +fn version_summary(executable: &Path) -> Result { + let output = Command::new(executable) + .arg("-v") + .output() + .with_context(|| format!("failed to launch {}", executable.display()))?; + if !output.status.success() { + bail!( + "{} -v failed with status {}", + executable.display(), + render_exit_status(output.status) + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let picked = stdout + .lines() + .filter(|line| { + line.starts_with("aria2 version ") + || line.starts_with("Enabled Features:") + || line.starts_with("Compiler:") + || line.starts_with(" built by ") + || line.starts_with(" targeting ") + || line.starts_with(" on ") + || line.starts_with("Pro Build:") + || line.starts_with("Pro Commit:") + }) + .map(str::trim) + .collect::>(); + Ok(picked.join("; ")) +} + +#[doc(hidden)] +fn find_rust_cli_executable(target_root: &Path) -> Option { + let candidate = target_root.join("release").join("aria2-rust-pro.exe"); + candidate.is_file().then_some(candidate) +} + +#[doc(hidden)] +fn invoke_same_host_local_http_comparison( + rust_executable: &Path, + pro_core_executable: &Path, + upstream_executable: &Path, + transfer_bytes: usize, + same_host_runs: usize, +) -> Result { + let temp_dir = + TempDir::new().context("failed to create temporary performance work directory")?; + let payload_path = temp_dir.path().join("payload.bin"); + let payload_bytes = build_payload_bytes(transfer_bytes); + std::fs::write(&payload_path, &payload_bytes) + .with_context(|| format!("failed to write {}", payload_path.display()))?; + let payload_sha256 = sha256_hex_bytes(&payload_bytes); + + let scenarios = [ + ScenarioSpec { + label: "single_file_split1", + description: "One loopback HTTP file with split=1 and max-connection-per-server=1", + split: 1, + max_connection_per_server: 1, + route_paths: &["/payload.bin"], + expected_outputs: &["payload.bin"], + }, + ScenarioSpec { + label: "segmented_single_file", + description: "One loopback HTTP file with split=4 and max-connection-per-server=4", + split: 4, + max_connection_per_server: 4, + route_paths: &["/segmented.bin"], + expected_outputs: &["segmented.bin"], + }, + ]; + + let mut results = Vec::with_capacity(scenarios.len()); + for scenario in scenarios { + let rust_dir = temp_dir.path().join("rust").join(scenario.label); + let pro_dir = temp_dir.path().join("procore").join(scenario.label); + let upstream_dir = temp_dir.path().join("upstream").join(scenario.label); + std::fs::create_dir_all(&rust_dir) + .with_context(|| format!("failed to create {}", rust_dir.display()))?; + std::fs::create_dir_all(&pro_dir) + .with_context(|| format!("failed to create {}", pro_dir.display()))?; + std::fs::create_dir_all(&upstream_dir) + .with_context(|| format!("failed to create {}", upstream_dir.display()))?; + results.push(ComparisonScenarioResult { + label: scenario.label, + description: scenario.description, + rust: invoke_same_host_local_http_scenario( + rust_executable, + &rust_dir, + &payload_path, + &payload_sha256, + &scenario, + same_host_runs, + )?, + pro_core: invoke_same_host_local_http_scenario( + pro_core_executable, + &pro_dir, + &payload_path, + &payload_sha256, + &scenario, + same_host_runs, + )?, + upstream: invoke_same_host_local_http_scenario( + upstream_executable, + &upstream_dir, + &payload_path, + &payload_sha256, + &scenario, + same_host_runs, + )?, + }); + } + + Ok(SameHostLocalHttpComparison { + payload_bytes: transfer_bytes, + payload_sha256, + scenarios: results, + }) +} + +#[doc(hidden)] +fn build_payload_bytes(len: usize) -> Vec { + (0..len) + .map(|index| u8::try_from(index % 251).expect("modulo 251 always fits in u8")) + .collect() +} + +#[doc(hidden)] +fn invoke_same_host_local_http_scenario( + executable_path: &Path, + working_directory: &Path, + payload_path: &Path, + expected_hash: &str, + scenario: &ScenarioSpec, + same_host_runs: usize, +) -> Result { + if same_host_runs == 0 { + bail!("same-host comparison requires at least one timed run"); + } + let server = LoopbackHttpTransferServer::start(payload_path, scenario.route_paths)?; + let scenario_uris = build_scenario_uris(&server, scenario.route_paths); + let warmup_directory = working_directory.join("_warmup"); + std::fs::create_dir_all(&warmup_directory) + .with_context(|| format!("failed to create {}", warmup_directory.display()))?; + let warmup_config_path = new_transfer_config( + &warmup_directory, + scenario.split, + scenario.max_connection_per_server, + )?; + let _warmup_run = invoke_timed_transfer_process( + executable_path, + &build_transfer_invocation_arguments(&warmup_config_path, &scenario_uris), + &warmup_directory, + Duration::from_secs(30), + )?; + + let mut observations = Vec::with_capacity(same_host_runs); + for sample_index in 0..same_host_runs { + let run_directory = working_directory.join(format!("run-{sample_index:02}")); + std::fs::create_dir_all(&run_directory) + .with_context(|| format!("failed to create {}", run_directory.display()))?; + let config_path = new_transfer_config( + &run_directory, + scenario.split, + scenario.max_connection_per_server, + )?; + let run = invoke_timed_transfer_process( + executable_path, + &build_transfer_invocation_arguments(&config_path, &scenario_uris), + &run_directory, + Duration::from_secs(30), + )?; + observations.push(collect_timed_transfer_observation( + &run_directory, + expected_hash, + scenario.expected_outputs, + &run, + )?); + } + drop(server); + if let Some((sample_index, observation)) = + observations.iter().enumerate().find(|(_, observation)| { + observation.output_count != scenario.expected_outputs.len() + || !observation.all_hashes_match + }) + { + let failed_directory = working_directory.join(format!("run-{sample_index:02}")); + let stdout = read_optional_log(&failed_directory.join("stdout.log"))?; + let stderr = read_optional_log(&failed_directory.join("stderr.log"))?; + let files = list_directory_entries(&failed_directory)?; + bail!( + "{} sample {} produced invalid output: {}/{} files matched, {} bytes, hashes_match={}; dir={}; files=[{}]; stdout={:?}; stderr={:?}", + scenario.label, + sample_index, + observation.output_count, + scenario.expected_outputs.len(), + observation.output_bytes, + observation.all_hashes_match, + failed_directory.display(), + files.join(", "), + stdout, + stderr + ); + } + + let mut elapsed_samples = observations + .iter() + .map(|observation| observation.elapsed) + .collect::>(); + elapsed_samples.sort_unstable(); + let fastest = *elapsed_samples + .first() + .expect("same_host_runs > 0 guarantees at least one elapsed sample"); + let slowest = *elapsed_samples + .last() + .expect("same_host_runs > 0 guarantees at least one elapsed sample"); + let elapsed = elapsed_samples[elapsed_samples.len() / 2]; + + let exit_code = observations.first().and_then(|first| { + observations + .iter() + .all(|observation| observation.exit_code == first.exit_code) + .then_some(first.exit_code) + .flatten() + }); + let output_count = observations + .last() + .map(|observation| observation.output_count) + .unwrap_or_default(); + let output_bytes = observations + .last() + .map(|observation| observation.output_bytes) + .unwrap_or_default(); + + Ok(TimedTransferSummary { + exit_code, + elapsed, + fastest, + slowest, + sample_count: observations.len(), + timed_out: observations.iter().any(|observation| observation.timed_out), + output_count, + output_bytes, + all_hashes_match: observations + .iter() + .all(|observation| observation.all_hashes_match), + }) +} + +#[doc(hidden)] +fn read_optional_log(path: &Path) -> Result { + if path.is_file() { + std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display())) + } else { + Ok(String::new()) + } +} + +#[doc(hidden)] +fn list_directory_entries(path: &Path) -> Result> { + if !path.is_dir() { + return Ok(Vec::new()); + } + let mut entries = std::fs::read_dir(path) + .with_context(|| format!("failed to read {}", path.display()))? + .filter_map(std::result::Result::ok) + .map(|entry| { + let path = entry.path(); + let len = path.metadata().map_or(0, |metadata| metadata.len()); + format!( + "{}:{}", + path.file_name() + .map(|name| OsStr::to_string_lossy(name).into_owned()) + .unwrap_or_default(), + len + ) + }) + .collect::>(); + entries.sort_unstable(); + Ok(entries) +} + +#[doc(hidden)] +fn collect_timed_transfer_observation( + working_directory: &Path, + expected_hash: &str, + expected_outputs: &[&str], + run: &TimedProcessResult, +) -> Result { + let outputs = expected_outputs + .iter() + .map(|expected_output| { + let output_path = working_directory.join(expected_output); + let exists = output_path.is_file(); + let length = if exists { + Some( + std::fs::metadata(&output_path) + .with_context(|| { + format!("failed to read metadata for {}", output_path.display()) + })? + .len(), + ) + } else { + None + }; + let hash_match = if exists { + Some(sha256_hex_path(&output_path)? == expected_hash) + } else { + None + }; + Ok((exists, length.unwrap_or(0), hash_match.unwrap_or_default())) + }) + .collect::>>()?; + + Ok(TimedTransferObservation { + exit_code: run.exit_code, + elapsed: run.elapsed, + timed_out: run.timed_out, + output_count: outputs.iter().filter(|(exists, _, _)| *exists).count(), + output_bytes: outputs.iter().map(|(_, len, _)| *len).sum(), + all_hashes_match: outputs.iter().all(|(_, _, hash_match)| *hash_match), + }) +} + +#[doc(hidden)] +fn build_transfer_invocation_arguments(config_path: &Path, uris: &[String]) -> Vec { + std::iter::once(format!("--conf-path={}", config_path.display())) + .chain(uris.iter().cloned()) + .collect() +} + +#[doc(hidden)] +fn build_scenario_uris(server: &LoopbackHttpTransferServer, route_paths: &[&str]) -> Vec { + route_paths + .iter() + .map(|route| format!("{}{}", server.base_url(), route)) + .collect() +} + +#[doc(hidden)] +fn new_transfer_config( + directory_path: &Path, + split: usize, + max_connection_per_server: usize, +) -> Result { + let config_path = directory_path.join("aria2.conf"); + let contents = [ + format!("dir={}", directory_path.display()), + format!("split={split}"), + format!("max-connection-per-server={max_connection_per_server}"), + "min-split-size=1M".to_owned(), + "piece-length=1M".to_owned(), + ] + .join("\n"); + write_utf8_text(&config_path, &contents)?; + Ok(config_path) +} + +#[doc(hidden)] +#[derive(Debug)] +struct TimedProcessResult { + #[doc(hidden)] + exit_code: Option, + #[doc(hidden)] + elapsed: Duration, + #[doc(hidden)] + timed_out: bool, +} + +/// Poll interval used while timing short-lived child processes in local +/// same-host comparisons. +const LOCAL_COMPARISON_WAIT_POLL: Duration = Duration::from_millis(1); + +#[doc(hidden)] +fn invoke_timed_transfer_process( + executable_path: &Path, + arguments: &[String], + working_directory: &Path, + timeout: Duration, +) -> Result { + let stdout_path = working_directory.join("stdout.log"); + let stderr_path = working_directory.join("stderr.log"); + let stdout = File::create(&stdout_path) + .with_context(|| format!("failed to create {}", stdout_path.display()))?; + let stderr = File::create(&stderr_path) + .with_context(|| format!("failed to create {}", stderr_path.display()))?; + let mut child = Command::new(executable_path) + .args(arguments) + .current_dir(working_directory) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .with_context(|| format!("failed to launch {}", executable_path.display()))?; + + let started = Instant::now(); + let mut timed_out = false; + let exit_code = loop { + if let Some(status) = child + .try_wait() + .with_context(|| format!("failed while waiting for {}", executable_path.display()))? + { + break status.code(); + } + if started.elapsed() >= timeout { + timed_out = true; + kill_child(&mut child); + let status = child.wait().with_context(|| { + format!("failed while waiting for {}", executable_path.display()) + })?; + break status.code(); + } + thread::sleep(LOCAL_COMPARISON_WAIT_POLL); + }; + + Ok(TimedProcessResult { + exit_code, + elapsed: started.elapsed(), + timed_out, + }) +} + +#[doc(hidden)] +fn kill_child(child: &mut Child) { + let _ = child.kill(); +} + +#[doc(hidden)] +struct LoopbackHttpTransferServer { + #[doc(hidden)] + stop: Arc, + #[doc(hidden)] + join_handle: Option>, + #[doc(hidden)] + base_url: String, + #[doc(hidden)] + listen_address: SocketAddr, +} + +#[doc(hidden)] +struct LoopbackServerState { + #[doc(hidden)] + listener: TcpListener, + #[doc(hidden)] + payload_bytes: Arc<[u8]>, + #[doc(hidden)] + route_lookup: Arc>, + #[doc(hidden)] + stop: Arc, +} + +impl LoopbackHttpTransferServer { + #[doc(hidden)] + fn start(payload_path: &Path, route_paths: &[&str]) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .context("failed to bind loopback transfer server")?; + let address = listener + .local_addr() + .context("failed to read loopback transfer server address")?; + let payload_bytes = Arc::<[u8]>::from( + std::fs::read(payload_path) + .with_context(|| format!("failed to read {}", payload_path.display()))?, + ); + let route_lookup = Arc::new( + route_paths + .iter() + .map(|route| { + if route.starts_with('/') { + (*route).to_string() + } else { + format!("/{route}") + } + }) + .collect::>(), + ); + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = Arc::clone(&stop); + let state = LoopbackServerState { + listener, + payload_bytes, + route_lookup, + stop: stop_flag, + }; + let join_handle = thread::spawn(move || { + run_loopback_transfer_server(state); + }); + + Ok(Self { + stop, + join_handle: Some(join_handle), + base_url: format!("http://127.0.0.1:{}", address.port()), + listen_address: address, + }) + } + + #[doc(hidden)] + fn base_url(&self) -> &str { + &self.base_url + } +} + +impl Drop for LoopbackHttpTransferServer { + #[doc(hidden)] + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.listen_address); + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +#[doc(hidden)] +fn run_loopback_transfer_server(state: LoopbackServerState) { + let LoopbackServerState { + listener, + payload_bytes, + route_lookup, + stop, + } = state; + loop { + let Ok((stream, _)) = listener.accept() else { + break; + }; + if stop.load(Ordering::SeqCst) { + break; + } + let _ = handle_transfer_connection(stream, &payload_bytes, &route_lookup); + } +} + +#[doc(hidden)] +fn handle_transfer_connection( + mut stream: TcpStream, + payload_bytes: &[u8], + route_lookup: &HashSet, +) -> Result<()> { + let mut request_reader = BufReader::new( + stream + .try_clone() + .context("failed to clone transfer stream for reading")?, + ); + let mut request_line = String::new(); + let read = request_reader + .read_line(&mut request_line) + .context("failed to read request line")?; + if read == 0 || request_line.trim().is_empty() { + return Ok(()); + } + + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("GET").to_ascii_uppercase(); + let raw_path = parts.next().unwrap_or("/payload.bin"); + let request_path = raw_path.split('?').next().unwrap_or("/payload.bin"); + + let mut range_header = None; + loop { + let mut header_line = String::new(); + let read = request_reader + .read_line(&mut header_line) + .context("failed to read request header")?; + if read == 0 || header_line == "\r\n" || header_line == "\n" { + break; + } + if let Some(value) = header_line.strip_prefix("Range:") { + range_header = Some(value.trim().to_owned()); + } + } + + if !route_lookup.contains(request_path) { + write_response( + &mut stream, + "HTTP/1.1 404 Not Found", + &[("Content-Length", "0"), ("Connection", "close")], + &[], + false, + )?; + return Ok(()); + } + + let (start, end_inclusive, length, status_line) = + resolve_response_range(range_header.as_deref(), payload_bytes.len()); + + let mut headers = vec![ + ("Content-Length".to_owned(), length.to_string()), + ( + "Content-Type".to_owned(), + "application/octet-stream".to_owned(), + ), + ("Connection".to_owned(), "close".to_owned()), + ]; + if status_line.contains("206") { + headers.push(( + "Content-Range".to_owned(), + format!("bytes {start}-{end_inclusive}/{}", payload_bytes.len()), + )); + } + + let body = if method == "HEAD" || length == 0 { + Vec::new() + } else { + payload_bytes + .get(start..=end_inclusive) + .map_or_else(Vec::new, <[u8]>::to_vec) + }; + let header_refs = headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect::>(); + write_response( + &mut stream, + status_line, + &header_refs, + &body, + method == "HEAD", + )?; + Ok(()) +} + +#[doc(hidden)] +fn parse_range_header(range_header: &str, total_len: usize) -> Option<(usize, usize)> { + let range_value = range_header.strip_prefix("bytes=")?; + let (start_text, end_text) = range_value.split_once('-')?; + let start = start_text.parse::().ok()?; + if start >= total_len { + return None; + } + let end = if end_text.is_empty() { + total_len.saturating_sub(1) + } else { + end_text + .parse::() + .ok()? + .min(total_len.saturating_sub(1)) + }; + Some((start, end)) +} + +#[doc(hidden)] +fn resolve_response_range( + range_header: Option<&str>, + payload_len: usize, +) -> (usize, usize, usize, &'static str) { + if payload_len == 0 { + return (0, 0, 0, "HTTP/1.1 200 OK"); + } + + if let Some((start, end_inclusive)) = + range_header.and_then(|range_text| parse_range_header(range_text, payload_len)) + { + let length = end_inclusive.saturating_sub(start).saturating_add(1); + (start, end_inclusive, length, "HTTP/1.1 206 Partial Content") + } else { + let end_inclusive = payload_len.saturating_sub(1); + (0, end_inclusive, payload_len, "HTTP/1.1 200 OK") + } +} + +#[doc(hidden)] +fn write_response( + stream: &mut TcpStream, + status_line: &str, + headers: &[(&str, &str)], + body: &[u8], + skip_body: bool, +) -> Result<()> { + let mut head = String::new(); + head.push_str(status_line); + head.push_str("\r\n"); + for (name, value) in headers { + head.push_str(name); + head.push_str(": "); + head.push_str(value); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream + .write_all(head.as_bytes()) + .context("failed to write transfer response head")?; + if !skip_body && !body.is_empty() { + stream + .write_all(body) + .context("failed to write transfer response body")?; + } + stream.flush().context("failed to flush transfer response") +} + +#[doc(hidden)] +fn sha256_hex_path(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("failed to read {}", path.display()))?; + if read == 0 { + break; + } + let chunk = buffer + .get(..read) + .ok_or_else(|| anyhow!("invalid read size {read} for {}", path.display()))?; + hasher.update(chunk); + } + Ok(format!("{:x}", hasher.finalize()).to_uppercase()) +} + +#[doc(hidden)] +fn sha256_hex_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)).to_uppercase() +} + +#[doc(hidden)] +fn build_report( + repo_root: &Path, + bench_executable: &Path, + manifest_path: &Path, + metrics: &BenchMetrics, + references: &ReferenceArtifactDetection, + same_host_comparison: Option<&SameHostLocalHttpComparison>, + rows: &[CriterionSummaryRow], +) -> Result { + let mut lines = Vec::new(); + lines.push("# Local Comparison".to_owned()); + lines.push(String::new()); + lines.push(format!("Generated: {}", current_local_timestamp()?)); + lines.push(String::new()); + lines.push("## Environment".to_owned()); + lines.push(String::new()); + lines.push(format!("- Repo root: {}", repo_root.display())); + lines.push(format!( + "- Bench executable: {}", + bench_executable.display() + )); + lines.push(format!("- Manifest: {}", manifest_path.display())); + if let Some(exit_code) = metrics.exit_code { + lines.push(format!("- Bench process exit code: {exit_code}")); + } else { + lines.push("- Bench process exit code: unavailable in this refresh".to_owned()); + } + if let Some(peak_bytes) = metrics.peak_working_set_bytes { + lines.push(format!( + "- Peak working set during local bench run: {peak_bytes} bytes" + )); + } else { + lines.push( + "- Peak working set during local bench run: unavailable in this refresh".to_owned(), + ); + } + if let Some(peak) = &metrics.peak_working_set_mib { + lines.push(format!( + "- Peak working set during local bench run: {peak} MiB" + )); + } + if let Some(handles) = metrics.peak_handle_count { + lines.push(format!( + "- Peak handle count during local bench run: {handles}" + )); + } else { + lines.push( + "- Peak handle count during local bench run: unavailable in this refresh".to_owned(), + ); + } + lines.push(String::new()); + lines.push("## Reference Artifact Detection".to_owned()); + lines.push(String::new()); + if let Some(path) = &references.rust_cli_executable { + lines.push(format!("- Rust CLI local executable: {}", path.display())); + } else { + lines.push("- Rust CLI local executable: not found under current target root".to_owned()); + } + if let Some(path) = &references.upstream_windows_executable { + lines.push(format!( + "- Original aria2 local executable: {}", + path.display() + )); + if let Some(summary) = &references.upstream_version_summary { + lines.push(format!("- Original aria2 version summary: {summary}")); + } + } else { + lines.push("- Original aria2 local executable: not found on this Windows host".to_owned()); + } + if let Some(path) = &references.pro_core_artifact { + lines.push(format!( + "- Current Pro Core local executable: {}", + path.display() + )); + if let Some(summary) = &references.pro_core_version_summary { + lines.push(format!("- Current Pro Core version summary: {summary}")); + } + } else { + lines.push( + "- Current Pro Core local executable: not found as a directly runnable local binary on this Windows host".to_owned(), + ); + } + if same_host_comparison.is_some() { + lines.push( + "- Comparison status: Rust local benchmarks and expanded same-host loopback HTTP comparisons were executed now across Rust, current Pro Core, and original upstream aria2 on this Windows host.".to_owned(), + ); + } else { + lines.push( + "- Comparison status: Rust local benchmarks were executed now; same-host side-by-side timing coverage still requires all three runnable binaries on this host.".to_owned(), + ); + } + lines.push(String::new()); + + if let Some(comparison) = same_host_comparison { + lines.push("## Same-Host Local HTTP Transfer Comparisons".to_owned()); + lines.push(String::new()); + lines.push(format!("- Payload bytes: {}", comparison.payload_bytes)); + lines.push(format!("- Payload SHA256: {}", comparison.payload_sha256)); + lines.push(String::new()); + for scenario in &comparison.scenarios { + lines.push(format!("### {}", scenario.label)); + lines.push(String::new()); + lines.push(format!("- {}", scenario.description)); + lines.push(String::new()); + lines.push( + "| Binary | Exit code | Samples | Median | Spread | Output count | Output bytes | SHA256 all match |".to_owned(), + ); + lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |".to_owned()); + lines.push(format!( + "| Rust CLI | {} | {} | {} ms | {} .. {} ms | {} | {} | {} |", + render_exit_code(scenario.rust.exit_code, scenario.rust.timed_out), + scenario.rust.sample_count, + format_elapsed_milliseconds(scenario.rust.elapsed), + format_elapsed_milliseconds(scenario.rust.fastest), + format_elapsed_milliseconds(scenario.rust.slowest), + scenario.rust.output_count, + scenario.rust.output_bytes, + scenario.rust.all_hashes_match + )); + lines.push(format!( + "| Pro Core | {} | {} | {} ms | {} .. {} ms | {} | {} | {} |", + render_exit_code(scenario.pro_core.exit_code, scenario.pro_core.timed_out), + scenario.pro_core.sample_count, + format_elapsed_milliseconds(scenario.pro_core.elapsed), + format_elapsed_milliseconds(scenario.pro_core.fastest), + format_elapsed_milliseconds(scenario.pro_core.slowest), + scenario.pro_core.output_count, + scenario.pro_core.output_bytes, + scenario.pro_core.all_hashes_match + )); + lines.push(format!( + "| Upstream aria2 | {} | {} | {} ms | {} .. {} ms | {} | {} | {} |", + render_exit_code(scenario.upstream.exit_code, scenario.upstream.timed_out), + scenario.upstream.sample_count, + format_elapsed_milliseconds(scenario.upstream.elapsed), + format_elapsed_milliseconds(scenario.upstream.fastest), + format_elapsed_milliseconds(scenario.upstream.slowest), + scenario.upstream.output_count, + scenario.upstream.output_bytes, + scenario.upstream.all_hashes_match + )); + lines.push(String::new()); + } + } + + lines.push(String::new()); + lines.push("## Rust Criterion Summary".to_owned()); + lines.push(String::new()); + lines.push("| Benchmark | Mean | 95% CI |".to_owned()); + lines.push("| --- | --- | --- |".to_owned()); + for row in rows { + lines.push(format!( + "| {} | {} | {} .. {} |", + row.key, row.mean, row.mean_lower, row.mean_upper + )); + } + lines.push(String::new()); + lines.push("## Notes".to_owned()); + lines.push(String::new()); + lines.extend([ + "- This report is a local comparison anchor, not a cross-host release benchmark.".to_owned(), + "- Memory evidence here is peak working set observed during the benchmark process. Peak handle count is currently only recorded when the runtime can provide it.".to_owned(), + "- The same-host HTTP comparison now covers a narrow single-connection case (split=1) plus a segmented single-file case (split=4) on the same local loopback server model.".to_owned(), + "- Each binary/scenario pair now performs one untimed warmup launch followed by repeated timed samples; the side-by-side table reports median elapsed plus fastest..slowest spread so single-run host noise is less likely to dominate the comparison.".to_owned(), + "- The same-host driver now avoids coarse sleep quantization in its short-process timing and loopback accept path, and it keeps each scenario on one warmed loopback server/port across the timed sample set so Windows first-port cold-path stalls do not dominate the Rust rows.".to_owned(), + "- Small connector-side wins such as no-proxy reqwest client reuse are still better validated through the direct `ARIA2_RUST_PRO_HTTP_TIMING` breakdown than by whole-process elapsed tables alone.".to_owned(), + "- The same-host side-by-side table is strongest as a local regression anchor. Some non-Rust reference rows can still pick up host-local cold-path noise, so this report should not be treated as a precision benchmark without corroborating direct runs.".to_owned(), + "- Shared-runtime multi-download pressure is still represented here through the Rust Criterion anchors rather than the side-by-side table.".to_owned(), + "- The upstream Windows reference now comes from the official aria2 1.37.0 release artifact downloaded into `H:\\Aria2\\aria2-rust-pro\\dist\\upstream\\`.".to_owned(), + ]); + + Ok(lines.join("\r\n") + "\r\n") +} + +#[doc(hidden)] +fn current_local_timestamp() -> Result { + let format = parse("[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]") + .context("failed to compile local timestamp format")?; + OffsetDateTime::now_utc() + .to_offset(UtcOffset::UTC) + .format(&format) + .context("failed to format local timestamp") +} + +#[doc(hidden)] +fn format_elapsed_milliseconds(duration: Duration) -> String { + let hundredths = duration.as_nanos().saturating_add(5_000) / 10_000; + format_fixed_decimal(hundredths, 2) +} + +#[doc(hidden)] +fn render_exit_code(exit_code: Option, timed_out: bool) -> String { + if timed_out { + "timeout".to_owned() + } else { + exit_code.map_or_else(|| "null".to_owned(), |code| code.to_string()) + } +} + +/// Converts a string slice into an owned OS string for command arguments. +fn os(value: &str) -> OsString { + OsString::from(value) +} + +/// Renders a process exit status for local comparison diagnostics. +fn render_exit_status(status: ExitStatus) -> String { + status + .code() + .map_or_else(|| "signal".to_owned(), |code| code.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{ + format_elapsed_milliseconds, format_estimate_ns, format_mib_hundredths, + parse_decimal_value, resolve_response_range, + }; + use std::time::Duration; + + #[doc(hidden)] + #[test] + fn decimal_estimates_format_expected_units() { + let milliseconds = parse_decimal_value("1234567.89").expect("decimal parse"); + let microseconds = parse_decimal_value("1234.567").expect("decimal parse"); + let nanoseconds = parse_decimal_value("12.34").expect("decimal parse"); + + assert_eq!(format_estimate_ns(&milliseconds), "1.2346 ms"); + assert_eq!(format_estimate_ns(µseconds), "1.235 us"); + assert_eq!(format_estimate_ns(&nanoseconds), "12.3 ns"); + } + + #[doc(hidden)] + #[test] + fn empty_payload_range_stays_non_slicing() { + assert_eq!( + resolve_response_range(Some("bytes=0-10"), 0), + (0, 0, 0, "HTTP/1.1 200 OK") + ); + assert_eq!( + resolve_response_range(None, 0), + (0, 0, 0, "HTTP/1.1 200 OK") + ); + } + + #[doc(hidden)] + #[test] + fn mib_and_elapsed_format_round_to_hundredths() { + assert_eq!(format_mib_hundredths(1_572_864), "1.50"); + assert_eq!( + format_elapsed_milliseconds(Duration::from_micros(12_345)), + "12.35" + ); + } +} diff --git a/xtask/src/release.rs b/xtask/src/release.rs new file mode 100644 index 0000000..15ec82b --- /dev/null +++ b/xtask/src/release.rs @@ -0,0 +1,614 @@ +use std::{ + ffi::{OsStr, OsString}, + fs::File, + io::{BufWriter, Read}, + path::{Path, PathBuf}, + process::ExitStatus, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use walkdir::WalkDir; +use zip::write::SimpleFileOptions; + +use crate::{ + cli::{PackageLocalArgs, ReleaseSmokeVersionArgs}, + workspace::{ + archive_extension_for_target, default_binary_path, host_triple, load_workspace_metadata, + root_manifest_path, run_command, utc_now_rfc3339, write_utf8_text, + }, +}; + +#[derive(Debug, Serialize)] +/// Summary printed after validating a release binary version. +struct ReleaseSmokeSummary { + #[serde(rename = "BinaryPath")] + /// Binary that was executed for the smoke check. + binary_path: String, + #[serde(rename = "HostTriple")] + /// Build host target triple. + host_triple: String, + #[serde(rename = "TargetTriple")] + /// Release artifact target triple. + target_triple: String, + #[serde(rename = "Version")] + /// Expected package version. + version: String, + #[serde(rename = "VersionOutput")] + /// Captured `--version` output. + version_output: String, +} + +#[derive(Debug, Serialize)] +/// Machine-readable manifest written beside a release archive. +struct ReleaseArchiveManifest { + /// Package version in the archive. + version: String, + /// Build host target triple. + host_triple: String, + /// Release artifact target triple. + target_triple: String, + /// Packaged archive metadata. + archive: ManifestArtifact, + /// Source binary metadata. + binary: ManifestArtifact, + /// Path to the aggregate checksum list. + checksum_list: String, + /// Version smoke result included in the package manifest. + version_smoke: VersionSmokeManifest, + /// Files included in the staged archive. + included_files: Vec, + /// UTC generation timestamp for the manifest. + generated_at_utc: String, +} + +#[derive(Debug, Serialize)] +/// File artifact metadata embedded in release manifests. +struct ManifestArtifact { + /// Artifact file name. + file_name: String, + /// Full artifact path. + path: String, + /// Hex-encoded SHA-256 digest. + sha256: String, +} + +#[derive(Debug, Serialize)] +/// Captured release version-smoke output. +struct VersionSmokeManifest { + /// Whether the smoke check was intentionally skipped. + skipped: bool, + /// Captured output lines from the smoke check. + output: Vec, +} + +#[derive(Debug, Serialize)] +/// File included in the staged release archive. +struct IncludedFile { + #[serde(rename = "RelativePath")] + /// Archive-relative file path. + relative_path: String, + #[serde(rename = "Size")] + /// File size in bytes. + size: u64, +} + +#[derive(Debug, Serialize)] +/// Summary printed after creating a local release package. +struct PackageSummary { + #[serde(rename = "Version")] + /// Package version in the archive. + version: String, + #[serde(rename = "HostTriple")] + /// Build host target triple. + host_triple: String, + #[serde(rename = "TargetTriple")] + /// Release artifact target triple. + target_triple: String, + #[serde(rename = "SourceBinary")] + /// Binary copied into the archive. + source_binary: String, + #[serde(rename = "ArchivePath")] + /// Path to the generated archive. + archive_path: String, + #[serde(rename = "ManifestPath")] + /// Path to the generated manifest. + manifest_path: String, + #[serde(rename = "ChecksumList")] + /// Path to the aggregate checksum list. + checksum_list: String, +} + +/// Runs the release binary version smoke workflow. +pub fn run_release_smoke_version(args: &ReleaseSmokeVersionArgs) -> Result<()> { + let metadata = load_workspace_metadata()?; + let host_triple = host_triple()?; + let target_triple = args + .target_triple + .clone() + .unwrap_or_else(|| host_triple.clone()); + + if args.build { + invoke_release_build(&target_triple, &host_triple)?; + } + + let binary_path = args + .binary_path + .clone() + .unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple)); + let version_output = invoke_version_smoke(&binary_path, &metadata.package_version)?; + let binary_path_text = path_display_string(&binary_path); + + let summary = ReleaseSmokeSummary { + binary_path: binary_path_text, + host_triple, + target_triple, + version: metadata.package_version, + version_output: version_output.join("\n"), + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize release smoke summary")? + ); + Ok(()) +} + +/// Builds a local release archive and manifest from a compiled binary. +pub fn run_package_local(args: &PackageLocalArgs) -> Result<()> { + let metadata = load_workspace_metadata()?; + let host_triple = host_triple()?; + let target_triple = args + .target_triple + .clone() + .unwrap_or_else(|| host_triple.clone()); + let output_root = args + .output_root + .clone() + .unwrap_or_else(|| metadata.workspace_root.join("dist").join("release")); + + if args.build { + invoke_release_build(&target_triple, &host_triple)?; + } + + let source_binary = args + .source_binary + .clone() + .unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple)); + ensure_file_exists(&source_binary, "release packaging could not find binary")?; + + let artifact_base_name = format!( + "aria2-rust-pro-v{}-{}", + metadata.package_version, target_triple + ); + let archive_extension = archive_extension_for_target(&target_triple); + let version_dir = output_root.join(format!("v{}", metadata.package_version)); + std::fs::create_dir_all(&version_dir) + .with_context(|| format!("failed to create {}", version_dir.display()))?; + + let version_output = if args.skip_version_smoke { + Vec::new() + } else { + invoke_version_smoke(&source_binary, &metadata.package_version)? + }; + + let stage = StageDirectory::new(&artifact_base_name)?; + let binary_name = file_name_os_string(&source_binary)?; + std::fs::copy(&source_binary, stage.stage_root.join(&binary_name)) + .with_context(|| format!("failed to stage {}", source_binary.display()))?; + + if target_triple.contains("windows") + && let Some(pdb_path) = find_windows_pdb_path(&source_binary) + { + let pdb_name = file_name_os_string(&pdb_path)?; + std::fs::copy(&pdb_path, stage.stage_root.join(&pdb_name)) + .with_context(|| format!("failed to stage {}", pdb_path.display()))?; + } + + add_optional_file( + &metadata.workspace_root.join("README.md"), + &stage.stage_root, + Path::new("README.md"), + )?; + add_optional_file( + &metadata + .workspace_root + .join("docs") + .join("release") + .join("README.md"), + &stage.stage_root, + Path::new("docs") + .join("release") + .join("README.md") + .as_path(), + )?; + + let archive_path = version_dir.join(format!("{artifact_base_name}{archive_extension}")); + if archive_extension == ".zip" { + create_zip_archive(&stage.stage_root, &archive_path)?; + } else { + create_tar_gz_archive(&stage.stage_root, &archive_path)?; + } + + let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?; + let binary_hash = sha256_hex(&source_binary)?; + let archive_hash = sha256_hex(&archive_path)?; + let manifest_path = version_dir.join(format!("{artifact_base_name}.manifest.json")); + let included_files = collect_included_files(&stage.stage_root)?; + let archive_path_text = path_display_string(&archive_path); + let source_binary_text = path_display_string(&source_binary); + let checksum_list_text = path_display_string(&checksum_list); + let manifest_path_text = path_display_string(&manifest_path); + let manifest = ReleaseArchiveManifest { + version: metadata.package_version.clone(), + host_triple: host_triple.clone(), + target_triple: target_triple.clone(), + archive: ManifestArtifact { + file_name: file_name_string(&archive_path)?, + path: archive_path_text.clone(), + sha256: archive_hash, + }, + binary: ManifestArtifact { + file_name: file_name_string(&source_binary)?, + path: source_binary_text.clone(), + sha256: binary_hash, + }, + checksum_list: checksum_list_text.clone(), + version_smoke: VersionSmokeManifest { + skipped: args.skip_version_smoke, + output: version_output, + }, + included_files, + generated_at_utc: utc_now_rfc3339()?, + }; + let manifest_text = + serde_json::to_string_pretty(&manifest).context("failed to serialize release manifest")?; + write_utf8_text(&manifest_path, &(manifest_text + "\n"))?; + + let summary = PackageSummary { + version: metadata.package_version, + host_triple, + target_triple, + source_binary: source_binary_text, + archive_path: archive_path_text, + manifest_path: manifest_path_text, + checksum_list: checksum_list_text, + }; + println!( + "{}", + serde_json::to_string_pretty(&summary) + .context("failed to serialize release package summary")? + ); + Ok(()) +} + +/// Executes a binary and validates its aria2-compatible version banner. +fn invoke_version_smoke(binary_path: &Path, expected_version: &str) -> Result> { + ensure_file_exists(binary_path, "version smoke could not find binary")?; + let output = std::process::Command::new(binary_path) + .arg("--version") + .output() + .with_context(|| format!("failed to launch {}", binary_path.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "version smoke failed with status {}: {}", + render_exit_status(output.status), + stderr.trim() + ); + } + + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("{} emitted non-UTF-8 stdout", binary_path.display()))?; + let lines = stdout.lines().map(str::to_owned).collect::>(); + let first_line = lines + .first() + .ok_or_else(|| anyhow!("version smoke output was empty"))?; + let expected_compat_line = "aria2 version 1.37.0"; + if !first_line.starts_with(expected_compat_line) { + bail!("version smoke output did not start with `{expected_compat_line}`: {first_line}"); + } + + let expected_package_line = format!("Rust rewrite package: aria2-rust-pro {expected_version}"); + let package_line = lines + .iter() + .find(|line| line.starts_with("Rust rewrite package:")) + .ok_or_else(|| { + anyhow!("version smoke output did not include a Rust rewrite package line") + })?; + if package_line != &expected_package_line { + bail!("version smoke package line did not match `{expected_package_line}`: {package_line}"); + } + + Ok(lines) +} + +/// Invokes the release build for the requested target triple. +fn invoke_release_build(target_triple: &str, host_triple: &str) -> Result<()> { + let manifest_path = root_manifest_path(); + let mut args = vec![ + os("+nightly"), + os("build"), + os("-Zbuild-std=std,panic_abort"), + os("--manifest-path"), + manifest_path.into_os_string(), + os("-p"), + os("aria2-rust-pro-cli"), + os("--bin"), + os("aria2-rust-pro"), + os("--release"), + ]; + if target_triple != host_triple { + args.push(os("--target")); + args.push(os(target_triple)); + } + run_command("cargo", &args) +} + +/// Temporary release staging directory. +struct StageDirectory { + /// Temporary directory whose lifetime keeps the staging tree alive. + _temp_dir: TempDir, + /// Root directory containing staged archive contents. + stage_root: PathBuf, +} + +impl StageDirectory { + /// Creates a new temporary staging directory for one artifact. + fn new(artifact_base_name: &str) -> Result { + let temp_dir = + TempDir::new().context("failed to create temporary release staging directory")?; + let stage_root = temp_dir.path().join(artifact_base_name); + std::fs::create_dir_all(&stage_root) + .with_context(|| format!("failed to create {}", stage_root.display()))?; + Ok(Self { + _temp_dir: temp_dir, + stage_root, + }) + } +} + +/// Ensures an expected release input file exists. +fn ensure_file_exists(path: &Path, label: &str) -> Result<()> { + if path.is_file() { + Ok(()) + } else { + bail!("{label}: {}", path.display()) + } +} + +/// Finds the optional Windows PDB companion for a release binary. +fn find_windows_pdb_path(binary_path: &Path) -> Option { + let mut candidates = Vec::new(); + let mut direct = binary_path.to_path_buf(); + direct.set_extension("pdb"); + candidates.push(direct); + let alt = binary_path + .parent() + .map(|parent| parent.join("aria2_rust_pro.pdb")); + if let Some(path) = alt { + candidates.push(path); + } + candidates.into_iter().find(|candidate| candidate.is_file()) +} + +/// Copies an optional repository file into the staging tree. +fn add_optional_file( + source_path: &Path, + destination_root: &Path, + relative_path: &Path, +) -> Result { + if !source_path.is_file() { + return Ok(false); + } + + let destination_path = destination_root.join(relative_path); + if let Some(parent) = destination_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::copy(source_path, &destination_path).with_context(|| { + format!( + "failed to copy {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + Ok(true) +} + +/// Creates a zip archive from the staged release tree. +fn create_zip_archive(stage_root: &Path, archive_path: &Path) -> Result<()> { + if archive_path.exists() { + std::fs::remove_file(archive_path) + .with_context(|| format!("failed to remove {}", archive_path.display()))?; + } + + let archive_root = file_name_string(stage_root)?; + let file = File::create(archive_path) + .with_context(|| format!("failed to create {}", archive_path.display()))?; + let writer = BufWriter::new(file); + let mut zip = zip::ZipWriter::new(writer); + let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + for entry in WalkDir::new(stage_root).sort_by_file_name() { + let entry = entry.with_context(|| format!("failed to walk {}", stage_root.display()))?; + let path = entry.path(); + let relative = path.strip_prefix(stage_root).with_context(|| { + format!( + "failed to strip {} from {}", + stage_root.display(), + path.display() + ) + })?; + let archive_name = if relative.as_os_str().is_empty() { + archive_root.clone() + } else { + format!( + "{archive_root}/{}", + relative.to_string_lossy().replace('\\', "/") + ) + }; + + if entry.file_type().is_dir() { + if !relative.as_os_str().is_empty() { + zip.add_directory(format!("{archive_name}/"), options) + .with_context(|| { + format!("failed to add directory {} to zip", path.display()) + })?; + } + continue; + } + + zip.start_file(&archive_name, options) + .with_context(|| format!("failed to add file {} to zip", path.display()))?; + let mut input = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + std::io::copy(&mut input, &mut zip) + .with_context(|| format!("failed to copy {} into zip", path.display()))?; + } + + zip.finish().context("failed to finalize zip archive")?; + Ok(()) +} + +/// Creates a gzip-compressed tar archive from the staged release tree. +fn create_tar_gz_archive(stage_root: &Path, archive_path: &Path) -> Result<()> { + if archive_path.exists() { + std::fs::remove_file(archive_path) + .with_context(|| format!("failed to remove {}", archive_path.display()))?; + } + + let file = File::create(archive_path) + .with_context(|| format!("failed to create {}", archive_path.display()))?; + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + let archive_root = stage_root.file_name().ok_or_else(|| { + anyhow!( + "stage root did not include a directory name: {}", + stage_root.display() + ) + })?; + builder + .append_dir_all(archive_root, stage_root) + .with_context(|| format!("failed to package {}", stage_root.display()))?; + builder + .finish() + .context("failed to finalize tar.gz archive")?; + Ok(()) +} + +/// Writes per-artifact and aggregate SHA-256 checksum files. +fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result { + let mut archive_candidates = std::fs::read_dir(output_directory) + .with_context(|| format!("failed to read {}", output_directory.display()))? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file() && is_archive_path(path)) + .collect::>(); + archive_candidates.sort(); + + for path in paths { + let hash = sha256_hex(path)?; + let file_name = file_name_string(path)?; + let line = format!("{hash} *{file_name}\n"); + write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?; + } + + let mut lines = Vec::with_capacity(archive_candidates.len()); + for path in archive_candidates { + let hash = sha256_hex(&path)?; + let file_name = file_name_string(&path)?; + lines.push(format!("{hash} *{file_name}")); + } + + let checksum_list_path = output_directory.join("SHA256SUMS.txt"); + write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?; + Ok(checksum_list_path) +} + +/// Computes a hex-encoded SHA-256 digest for a file. +fn sha256_hex(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = Box::new([0_u8; 64 * 1024]); + loop { + let read = file + .read(&mut buffer[..]) + .with_context(|| format!("failed to read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// Collects files included in the staged release tree. +fn collect_included_files(stage_root: &Path) -> Result> { + WalkDir::new(stage_root) + .sort_by_file_name() + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.file_type().is_file()) + .map(|entry| { + let relative = entry.path().strip_prefix(stage_root).with_context(|| { + format!( + "failed to strip {} from {}", + stage_root.display(), + entry.path().display() + ) + })?; + let metadata = entry.metadata().with_context(|| { + format!("failed to read metadata for {}", entry.path().display()) + })?; + Ok(IncludedFile { + relative_path: relative.to_string_lossy().replace('\\', "/"), + size: metadata.len(), + }) + }) + .collect() +} + +/// Returns the trailing file name as UTF-8 text for manifest entries. +fn file_name_string(path: &Path) -> Result { + path.file_name() + .ok_or_else(|| anyhow!("path did not include a file name: {}", path.display())) + .map(|name| OsStr::to_string_lossy(name).into_owned()) +} + +/// Returns the trailing file name as an owned OS string. +fn file_name_os_string(path: &Path) -> Result { + path.file_name() + .ok_or_else(|| anyhow!("path did not include a file name: {}", path.display())) + .map(OsStr::to_os_string) +} + +/// Renders a path display adapter into an owned string. +fn path_display_string(path: &Path) -> String { + format!("{}", path.display()) +} + +/// Converts a string slice into an owned OS string for command arguments. +fn os(value: &str) -> OsString { + OsString::from(value) +} + +/// Renders a process exit status for release diagnostics. +fn render_exit_status(status: ExitStatus) -> String { + status + .code() + .map_or_else(|| String::from("signal"), |code| code.to_string()) +} + +/// Returns whether a path looks like a release archive. +fn is_archive_path(path: &Path) -> bool { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_default(); + file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar") +} diff --git a/xtask/src/testing.rs b/xtask/src/testing.rs new file mode 100644 index 0000000..03b9200 --- /dev/null +++ b/xtask/src/testing.rs @@ -0,0 +1,172 @@ +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; + +use anyhow::Result; + +use crate::{ + cli::{ReleaseSmokeVersionArgs, StrictSweepArgs}, + release::run_release_smoke_version, + workspace::{repo_root, root_manifest_path, run_command_with_env}, +}; + +/// Runs the local strict quality sweep used before release or handoff. +pub fn run_strict_sweep(args: &StrictSweepArgs) -> Result<()> { + let repo_root = repo_root(); + let manifest_path = root_manifest_path(); + let deny_config_path = repo_root.join("deny.toml"); + let commands = vec![ + SweepCommand::new( + "fmt", + Some(repo_root.join("target-gate-fmt")), + vec![ + os("fmt"), + os("--all"), + os("--check"), + os("--manifest-path"), + path_arg(&manifest_path), + ], + ), + SweepCommand::new( + "check", + Some(repo_root.join("target-gate-check")), + vec![ + os("check"), + os("--workspace"), + os("--all-targets"), + os("--all-features"), + os("--locked"), + os("--manifest-path"), + path_arg(&manifest_path), + ], + ), + SweepCommand::new( + "nextest", + Some(repo_root.join("target-gate-nextest")), + vec![ + os("nextest"), + os("run"), + os("--workspace"), + os("--all-targets"), + os("--all-features"), + os("--locked"), + os("--manifest-path"), + path_arg(&manifest_path), + ], + ), + SweepCommand::new( + "clippy", + Some(repo_root.join("target-gate-clippy")), + vec![ + os("clippy"), + os("--workspace"), + os("--all-targets"), + os("--all-features"), + os("--locked"), + os("--no-deps"), + os("--manifest-path"), + path_arg(&manifest_path), + os("--"), + os("-D"), + os("warnings"), + os("-D"), + os("clippy::pedantic"), + os("-D"), + os("clippy::nursery"), + ], + ), + SweepCommand::new( + "udeps", + Some(repo_root.join("target-gate-udeps")), + vec![ + os("+nightly"), + os("udeps"), + os("--workspace"), + os("--all-targets"), + os("--all-features"), + os("--locked"), + os("--manifest-path"), + path_arg(&manifest_path), + ], + ), + SweepCommand::new( + "deny", + None, + vec![ + os("deny"), + os("--manifest-path"), + path_arg(&manifest_path), + os("--locked"), + os("check"), + os("--config"), + path_arg(&deny_config_path), + ], + ), + ]; + + for command in commands { + println!("==> {}", command.name); + let envs = build_envs(command.target_dir.as_deref(), command.name == "deny"); + run_command_with_env("cargo", &command.args, &envs, Some(&repo_root))?; + } + + if args.include_release_smoke { + println!("==> release-smoke"); + run_release_smoke_version(&ReleaseSmokeVersionArgs { + build: true, + target_triple: None, + binary_path: None, + })?; + } + + Ok(()) +} + +#[derive(Debug)] +/// A cargo command executed as part of the strict sweep. +struct SweepCommand { + /// Short label printed before executing the command. + name: &'static str, + /// Optional target directory isolating build artifacts. + target_dir: Option, + /// Arguments passed to cargo. + args: Vec, +} + +impl SweepCommand { + /// Builds a sweep command descriptor. + const fn new(name: &'static str, target_dir: Option, args: Vec) -> Self { + Self { + name, + target_dir, + args, + } + } +} + +/// Builds environment overrides for an individual sweep command. +fn build_envs(target_dir: Option<&Path>, cargo_deny: bool) -> Vec<(&'static str, OsString)> { + let mut envs = vec![("CARGO_INCREMENTAL", os("0"))]; + if let Some(target_dir) = target_dir { + envs.push(("CARGO_TARGET_DIR", target_dir.as_os_str().to_os_string())); + } + if cargo_deny { + envs.extend([ + ("GIT_CONFIG_COUNT", os("1")), + ("GIT_CONFIG_KEY_0", os("http.sslbackend")), + ("GIT_CONFIG_VALUE_0", os("openssl")), + ]); + } + envs +} + +/// Converts a string literal into an owned OS argument. +fn os(value: &str) -> OsString { + OsString::from(value) +} + +/// Converts the root manifest path into a fresh cargo argument. +fn path_arg(path: &Path) -> OsString { + path.as_os_str().to_os_string() +} diff --git a/xtask/src/workspace.rs b/xtask/src/workspace.rs new file mode 100644 index 0000000..d60e1a2 --- /dev/null +++ b/xtask/src/workspace.rs @@ -0,0 +1,239 @@ +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + process::{Command, ExitStatus}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use cargo_metadata::MetadataCommand; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[derive(Debug, Clone)] +/// Shared workspace metadata discovered from `cargo metadata`. +pub struct WorkspaceMetadata { + /// Absolute repository root of the workspace. + pub workspace_root: PathBuf, + /// Resolved Cargo target directory for the workspace. + pub target_directory: PathBuf, + /// Current package version of the CLI package. + pub package_version: String, + /// Canonical binary name produced by the workspace. + pub binary_name: String, +} + +/// Returns the repository root that contains the `xtask` crate. +pub fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(Path::to_path_buf) + .expect("xtask should live directly under the workspace root") +} + +/// Returns the absolute path to the workspace root manifest. +pub fn root_manifest_path() -> PathBuf { + repo_root().join("Cargo.toml") +} + +/// Loads shared workspace metadata used by multiple `xtask` workflows. +pub fn load_workspace_metadata() -> Result { + let manifest_path = root_manifest_path(); + let metadata = MetadataCommand::new() + .manifest_path(&manifest_path) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to run cargo metadata for {}", + manifest_path.display() + ) + })?; + let cli_package = metadata + .packages + .iter() + .find(|package| package.name == "aria2-rust-pro-cli") + .ok_or_else(|| anyhow!("cargo metadata did not return aria2-rust-pro-cli"))?; + + Ok(WorkspaceMetadata { + workspace_root: metadata.workspace_root.into_std_path_buf(), + target_directory: metadata.target_directory.into_std_path_buf(), + package_version: cli_package.version.to_string(), + binary_name: String::from("aria2-rust-pro"), + }) +} + +/// Returns the Rust host target triple reported by the current toolchain. +pub fn host_triple() -> Result { + let output = Command::new("rustc") + .arg("-vV") + .output() + .context("failed to launch rustc -vV")?; + if !output.status.success() { + bail!( + "rustc -vV failed with status {}", + render_exit_status(output.status) + ); + } + let stdout = String::from_utf8(output.stdout).context("rustc -vV emitted non-UTF-8 stdout")?; + stdout + .lines() + .find_map(|line| line.strip_prefix("host:").map(str::trim)) + .map(str::to_owned) + .ok_or_else(|| anyhow!("rustc -vV did not report a host triple")) +} + +/// Returns the binary filename for a target triple, including `.exe` on Windows. +pub fn binary_name_for_target(binary_name: &str, target_triple: &str) -> String { + if target_triple.contains("windows") { + format!("{binary_name}.exe") + } else { + String::from(binary_name) + } +} + +/// Returns the release archive extension for a target triple. +pub fn archive_extension_for_target(target_triple: &str) -> &'static str { + if target_triple.contains("windows") { + ".zip" + } else { + ".tar.gz" + } +} + +/// Returns the default release binary path for a target triple. +pub fn default_binary_path( + metadata: &WorkspaceMetadata, + target_triple: &str, + host_triple: &str, +) -> PathBuf { + let binary_name = binary_name_for_target(&metadata.binary_name, target_triple); + if target_triple == host_triple { + metadata.target_directory.join("release").join(binary_name) + } else { + metadata + .target_directory + .join(target_triple) + .join("release") + .join(binary_name) + } +} + +/// Runs a command and returns an error with a rendered command line when it fails. +pub fn run_command(program: &str, args: &[OsString]) -> Result<()> { + let rendered = render_command(program, args); + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("failed to launch `{rendered}`"))?; + if status.success() { + Ok(()) + } else { + bail!( + "`{rendered}` failed with status {}", + render_exit_status(status) + ) + } +} + +/// Runs a command with additional environment variables and reports contextual failures. +pub fn run_command_with_env( + program: &str, + args: &[OsString], + envs: &[(&str, OsString)], + working_dir: Option<&Path>, +) -> Result<()> { + let rendered = render_command(program, args); + let mut command = Command::new(program); + command.args(args); + if let Some(working_dir) = working_dir { + command.current_dir(working_dir); + } + for (key, value) in envs { + command.env(key, value); + } + let status = command + .status() + .with_context(|| format!("failed to launch `{rendered}`"))?; + if status.success() { + Ok(()) + } else { + bail!( + "`{rendered}` failed with status {}", + render_exit_status(status) + ) + } +} + +/// Captures UTF-8 stdout lines from a process that is expected to succeed. +pub fn capture_stdout_lines(program: &Path, args: &[&str]) -> Result> { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to launch {}", program.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "{} failed with status {}: {}", + program.display(), + render_exit_status(output.status), + stderr.trim() + ); + } + + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("{} emitted non-UTF-8 stdout", program.display()))?; + Ok(stdout.lines().map(str::to_owned).collect()) +} + +/// Writes newline-delimited UTF-8 text lines to a file, creating parent directories first. +pub fn write_utf8_lines(path: &Path, lines: &[String]) -> Result<()> { + let mut text = lines.join("\n"); + text.push('\n'); + write_utf8_text(path, &text) +} + +/// Writes UTF-8 text to a file, creating parent directories first. +pub fn write_utf8_text(path: &Path, text: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display())) +} + +/// Returns the current UTC timestamp formatted as `RFC 3339`. +pub fn utc_now_rfc3339() -> Result { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .context("failed to format current UTC timestamp") +} + +/// Renders a shell-style command preview for error messages. +fn render_command(program: &str, args: &[OsString]) -> String { + let rendered_args = args + .iter() + .map(|arg| quote_os(arg.as_os_str())) + .collect::>() + .join(" "); + if rendered_args.is_empty() { + String::from(program) + } else { + format!("{program} {rendered_args}") + } +} + +/// Quotes an OS string for readable command rendering when it contains spaces. +fn quote_os(value: &OsStr) -> String { + let text = value.to_string_lossy(); + if text.contains(' ') { + format!("\"{text}\"") + } else { + text.into_owned() + } +} + +/// Renders a process exit status for workspace command failures. +fn render_exit_status(status: ExitStatus) -> String { + status + .code() + .map_or_else(|| "signal".to_owned(), |code| code.to_string()) +}