From 17688c3e3421b40b84def4aa5ab6e4843a656fc5 Mon Sep 17 00:00:00 2001 From: Aria2 Rust Pro Contributors Date: Sat, 18 Jul 2026 14:04:26 +0800 Subject: [PATCH] chore: initial sanitized public snapshot --- .cargo/config.toml | 6 + .dockerignore | 37 + .gitattributes | 10 + .gitea/workflows/ci.yml | 94 + .gitea/workflows/release.yml | 217 ++ .gitignore | 33 + CHANGELOG.md | 51 + CONTRIBUTING.md | 134 + Cargo.lock | 2365 +++++++++++++++++ Cargo.toml | 91 + LICENSE | 339 +++ README.md | 144 + cliff.toml | 37 + crates/aria2-rust-pro-cli/Cargo.toml | 27 + crates/aria2-rust-pro-cli/src/args.rs | 511 ++++ crates/aria2-rust-pro-cli/src/http_runtime.rs | 39 + .../src/http_runtime/build.rs | 98 + .../src/http_runtime/execution.rs | 409 +++ .../src/http_runtime/persist.rs | 173 ++ .../src/http_runtime/planning.rs | 167 ++ crates/aria2-rust-pro-cli/src/lib.rs | 248 ++ crates/aria2-rust-pro-cli/src/main.rs | 21 + .../src/parallel_http_runtime.rs | 627 +++++ crates/aria2-rust-pro-cli/src/projection.rs | 708 +++++ crates/aria2-rust-pro-cli/src/rpc_daemon.rs | 126 + .../src/runtime_execution.rs | 88 + crates/aria2-rust-pro-cli/src/runtime_host.rs | 113 + .../src/runtime_planning.rs | 222 ++ .../aria2-rust-pro-cli/src/runtime_summary.rs | 54 + crates/aria2-rust-pro-cli/src/tests.rs | 930 +++++++ .../src/tests/command_surface.rs | 478 ++++ .../src/tests/integration_surface.rs | 9 + .../bt_runtime_and_tracker.rs | 105 + .../cli_overrides_and_proxy.rs | 334 +++ .../config_and_protocol_surface.rs | 141 + .../http_runtime_and_checksum.rs | 281 ++ .../input_file_and_source_order.rs | 225 ++ .../rpc_pressure_and_command_surface.rs | 222 ++ .../src/tests/runtime_execution.rs | 7 + .../runtime_execution/checksum_completion.rs | 63 + .../runtime_execution/live_http_connector.rs | 315 +++ .../runtime_execution/retry_and_partial.rs | 276 ++ .../runtime_execution/segment_parallelism.rs | 582 ++++ .../runtime_execution/segment_planning.rs | 420 +++ .../src/transfer_resolution.rs | 505 ++++ .../src/transfer_runtime.rs | 391 +++ crates/aria2-rust-pro-cli/src/types.rs | 277 ++ crates/aria2-rust-pro-compat/Cargo.toml | 17 + crates/aria2-rust-pro-compat/src/compat.rs | 137 + crates/aria2-rust-pro-compat/src/config.rs | 396 +++ crates/aria2-rust-pro-compat/src/help.rs | 427 +++ crates/aria2-rust-pro-compat/src/lib.rs | 133 + crates/aria2-rust-pro-compat/src/options.rs | 26 + .../src/options/model.rs | 316 +++ .../src/options/query.rs | 77 + .../src/options/registry.rs | 53 + .../compatibility_extension_entries.rs | 368 +++ .../options/registry/foundational_entries.rs | 422 +++ .../options/registry/hooks_and_rpc_entries.rs | 512 ++++ .../registry/transfer_tuning_entries.rs | 566 ++++ .../src/options/reserved.rs | 24 + .../src/options/tests.rs | 176 ++ crates/aria2-rust-pro-core/Cargo.toml | 20 + crates/aria2-rust-pro-core/src/engine.rs | 679 +++++ .../src/engine/bt_runtime.rs | 198 ++ .../src/engine/inspection.rs | 369 +++ .../aria2-rust-pro-core/src/engine/queue.rs | 204 ++ .../src/engine/scheduling.rs | 167 ++ .../src/engine/session_persistence.rs | 621 +++++ .../aria2-rust-pro-core/src/engine/tests.rs | 1527 +++++++++++ crates/aria2-rust-pro-core/src/error.rs | 66 + crates/aria2-rust-pro-core/src/events.rs | 143 + crates/aria2-rust-pro-core/src/lib.rs | 58 + crates/aria2-rust-pro-core/src/options.rs | 149 ++ crates/aria2-rust-pro-core/src/piece.rs | 104 + crates/aria2-rust-pro-core/src/progress.rs | 332 +++ crates/aria2-rust-pro-core/src/request.rs | 40 + crates/aria2-rust-pro-core/src/request/bt.rs | 414 +++ .../src/request/context.rs | 117 + .../aria2-rust-pro-core/src/request/group.rs | 153 ++ .../src/request/group/bt_peers.rs | 107 + .../src/request/group/bt_pieces.rs | 166 ++ .../src/request/group/bt_share.rs | 177 ++ .../src/request/group/model.rs | 51 + .../src/request/group/state.rs | 345 +++ .../src/request/identity.rs | 107 + .../src/request/request_tests.rs | 929 +++++++ .../src/request/segment.rs | 67 + crates/aria2-rust-pro-core/src/runtime.rs | 206 ++ crates/aria2-rust-pro-core/src/scheduler.rs | 559 ++++ .../src/scheduler/scheduler_tests.rs | 287 ++ crates/aria2-rust-pro-core/src/session.rs | 564 ++++ crates/aria2-rust-pro-protocol/Cargo.toml | 27 + crates/aria2-rust-pro-protocol/src/auth.rs | 59 + crates/aria2-rust-pro-protocol/src/bt.rs | 23 + .../src/bt_metalink.rs | 391 +++ .../aria2-rust-pro-protocol/src/downloader.rs | 88 + .../src/downloader/contracts.rs | 98 + .../src/downloader/core_downloader.rs | 193 ++ .../src/downloader/downloader_tests.rs | 953 +++++++ .../src/downloader/fixture_downloader.rs | 665 +++++ .../src/downloader/reqwest_connector.rs | 641 +++++ crates/aria2-rust-pro-protocol/src/ftp.rs | 106 + crates/aria2-rust-pro-protocol/src/http.rs | 22 + .../src/http/checksum.rs | 110 + .../aria2-rust-pro-protocol/src/http/model.rs | 509 ++++ .../src/http/progress.rs | 177 ++ .../aria2-rust-pro-protocol/src/http/tests.rs | 489 ++++ crates/aria2-rust-pro-protocol/src/lib.rs | 134 + crates/aria2-rust-pro-protocol/src/magnet.rs | 584 ++++ .../aria2-rust-pro-protocol/src/metalink.rs | 23 + .../src/metalink/model.rs | 137 + .../src/metalink/normalization.rs | 96 + .../src/metalink/parser.rs | 310 +++ .../src/metalink/planner.rs | 114 + .../src/metalink/tests.rs | 448 ++++ crates/aria2-rust-pro-protocol/src/session.rs | 120 + crates/aria2-rust-pro-protocol/src/sftp.rs | 107 + crates/aria2-rust-pro-protocol/src/torrent.rs | 39 + .../src/torrent/bencode.rs | 214 ++ .../src/torrent/dht.rs | 157 ++ .../src/torrent/dht/codec.rs | 177 ++ .../src/torrent/dht/compact.rs | 114 + .../src/torrent/dht/message.rs | 488 ++++ .../src/torrent/metadata.rs | 185 ++ .../src/torrent/model.rs | 281 ++ .../src/torrent/peer_wire.rs | 223 ++ .../src/torrent/peer_wire/extension.rs | 291 ++ .../src/torrent/peer_wire/framing.rs | 506 ++++ .../src/torrent/peer_wire/handshake.rs | 122 + .../src/torrent/tests.rs | 721 +++++ .../src/torrent/utils.rs | 69 + crates/aria2-rust-pro-protocol/src/tracker.rs | 47 + .../src/tracker/error.rs | 44 + .../src/tracker/parsing.rs | 491 ++++ .../src/tracker/request_response.rs | 303 +++ .../src/tracker/reqwest_transport.rs | 107 + .../src/tracker/tracker_tests.rs | 567 ++++ .../src/tracker/udp.rs | 490 ++++ .../aria2-rust-pro-protocol/src/transport.rs | 21 + .../src/transport/model.rs | 246 ++ .../src/transport/std_connectors.rs | 518 ++++ .../src/transport/tests.rs | 192 ++ crates/aria2-rust-pro-rpc/Cargo.toml | 28 + crates/aria2-rust-pro-rpc/src/dispatcher.rs | 419 +++ .../src/dispatcher/bt_runtime.rs | 31 + .../src/dispatcher/bt_runtime/dht.rs | 327 +++ .../src/dispatcher/bt_runtime/peer_wire.rs | 245 ++ .../src/dispatcher/bt_runtime/reporting.rs | 92 + .../dispatcher/bt_runtime/runtime_state.rs | 270 ++ .../src/dispatcher/bt_runtime/tracker.rs | 180 ++ .../src/dispatcher/compat_support.rs | 28 + .../dispatcher/compat_support/bt_runtime.rs | 362 +++ .../src/dispatcher/compat_support/dht.rs | 240 ++ .../dispatcher/compat_support/peer_wire.rs | 360 +++ .../dispatcher/compat_support/rpc_surface.rs | 91 + .../dispatcher/compat_support/selection.rs | 74 + .../src/dispatcher/compat_support/tracker.rs | 40 + .../src/dispatcher/dispatch_surface.rs | 264 ++ .../src/dispatcher/faults.rs | 46 + .../src/dispatcher/helpers.rs | 356 +++ .../src/dispatcher/mutations.rs | 362 +++ .../src/dispatcher/payloads.rs | 785 ++++++ .../src/dispatcher/queries.rs | 270 ++ .../src/dispatcher/tests.rs | 678 +++++ .../src/dispatcher/tests/bt_and_extension.rs | 7 + .../bt_and_extension/bt_status_and_magnet.rs | 336 +++ .../tests/bt_and_extension/dht_runtime.rs | 514 ++++ .../extensions_and_multicall.rs | 338 +++ .../bt_and_extension/peer_wire_runtime.rs | 777 ++++++ .../bt_and_extension/tracker_and_bridges.rs | 926 +++++++ .../src/dispatcher/tests/protocol_surface.rs | 379 +++ .../src/dispatcher/tests/queue_and_options.rs | 7 + .../queue_and_options/additions_and_state.rs | 488 ++++ .../queue_and_options/options_and_files.rs | 525 ++++ .../tests/queue_and_options/queue_and_uri.rs | 556 ++++ .../queue_views_and_transfer.rs | 7 + .../queue_views_and_transfer/file_views.rs | 105 + .../queue_mutation.rs | 155 ++ .../queue_views_and_transfer/queue_views.rs | 291 ++ .../session_and_shutdown.rs | 32 + .../transfer_runtime.rs | 464 ++++ .../queue_and_options/status_and_global.rs | 324 +++ .../src/dispatcher/transfer_runtime.rs | 454 ++++ crates/aria2-rust-pro-rpc/src/handlers.rs | 641 +++++ crates/aria2-rust-pro-rpc/src/jsonrpc.rs | 547 ++++ crates/aria2-rust-pro-rpc/src/lib.rs | 70 + crates/aria2-rust-pro-rpc/src/methods.rs | 337 +++ crates/aria2-rust-pro-rpc/src/model.rs | 217 ++ crates/aria2-rust-pro-rpc/src/router.rs | 280 ++ crates/aria2-rust-pro-rpc/src/server.rs | 38 + .../aria2-rust-pro-rpc/src/server/config.rs | 56 + .../src/server/http_surface.rs | 544 ++++ crates/aria2-rust-pro-rpc/src/server/tests.rs | 48 + .../src/server/tests/http_surface.rs | 519 ++++ .../src/server/tests/websocket_dispatch.rs | 272 ++ .../src/server/tests/websocket_handshake.rs | 205 ++ .../src/server/tests/websocket_session.rs | 331 +++ .../src/server/transport_runtime.rs | 463 ++++ .../src/server/websocket_dispatch.rs | 177 ++ .../src/server/websocket_handshake.rs | 84 + .../src/server/websocket_session.rs | 108 + .../src/server/websocket_surface.rs | 16 + .../src/server/websocket_wire.rs | 177 ++ crates/aria2-rust-pro-rpc/src/session.rs | 87 + crates/aria2-rust-pro-rpc/src/websocket.rs | 46 + .../src/websocket/bridge.rs | 65 + .../src/websocket/notification.rs | 189 ++ .../src/websocket/registry.rs | 219 ++ .../aria2-rust-pro-rpc/src/websocket/tests.rs | 397 +++ crates/aria2-rust-pro-rpc/src/xmlrpc.rs | 28 + crates/aria2-rust-pro-rpc/src/xmlrpc/codec.rs | 212 ++ .../aria2-rust-pro-rpc/src/xmlrpc/convert.rs | 77 + crates/aria2-rust-pro-rpc/src/xmlrpc/model.rs | 85 + .../aria2-rust-pro-rpc/src/xmlrpc/scanner.rs | 435 +++ crates/aria2-rust-pro-rpc/src/xmlrpc/tests.rs | 685 +++++ crates/aria2-rust-pro-storage/Cargo.toml | 17 + .../aria2-rust-pro-storage/src/allocation.rs | 47 + crates/aria2-rust-pro-storage/src/cache.rs | 59 + crates/aria2-rust-pro-storage/src/checksum.rs | 94 + crates/aria2-rust-pro-storage/src/control.rs | 17 + .../src/control/binary.rs | 474 ++++ .../src/control/model.rs | 124 + .../src/control/tests.rs | 201 ++ .../src/control/text.rs | 315 +++ crates/aria2-rust-pro-storage/src/disk.rs | 69 + crates/aria2-rust-pro-storage/src/io.rs | 317 +++ crates/aria2-rust-pro-storage/src/lib.rs | 50 + crates/aria2-rust-pro-storage/src/model.rs | 132 + crates/aria2-rust-pro-storage/src/resume.rs | 81 + crates/aria2-rust-pro-storage/src/session.rs | 330 +++ crates/aria2-rust-pro-storage/src/store.rs | 427 +++ crates/aria2-rust-pro-tests/Cargo.toml | 30 + .../benches/rpc_pressure.rs | 63 + .../benches/rpc_pressure/bt_visibility.rs | 202 ++ .../rpc_pressure/live_http_transfer.rs | 602 +++++ .../rpc_pressure/rpc_runtime_pressure.rs | 667 +++++ .../rpc_pressure/runtime_engine_pressure.rs | 206 ++ .../benches/rpc_pressure/support.rs | 41 + crates/aria2-rust-pro-tests/src/lib.rs | 36 + .../src/tests/bt_status_and_selection.rs | 701 +++++ .../src/tests/dht_and_peer_wire.rs | 682 +++++ .../src/tests/foundations_and_protocol.rs | 284 ++ .../src/tests/rpc_parity.rs | 306 +++ .../src/tests/rpc_pressure_and_runtime.rs | 617 +++++ .../aria2-rust-pro-tests/src/tests/support.rs | 340 +++ .../tests/tracker_and_surface_regression.rs | 236 ++ .../test_support/support.rs | 300 +++ .../tests/bt_cli_runtime.rs | 170 ++ .../tests/bt_magnet_promotion.rs | 168 ++ .../tests/bt_orchestration.rs | 317 +++ deny.toml | 34 + docker/.dockerignore | 4 + docker/.env.example | 13 + docker/Dockerfile | 53 + docker/defaults/aria2.conf | 9 + docker/defaults/bt-tracker.txt | 3 + docker/defaults/script.conf | 5 + docker/defaults/script/clean.sh | 4 + docker/defaults/script/delete.sh | 4 + docker/defaults/script/move.sh | 15 + docker/defaults/script/rclone.env | 1 + docker/defaults/script/tracker.sh | 44 + docker/defaults/script/upload.sh | 4 + docker/docker-compose.yml | 27 + docker/entrypoint.sh | 176 ++ .../adr/0001-rust-native-rewrite.md | 19 + docs/compatibility/aria2-compat-ledger.md | 103 + docs/compatibility/goldens/README.md | 28 + docs/compatibility/goldens/cli/manifest.json | 9 + .../goldens/cli/rust/help-all.txt | 220 ++ .../goldens/cli/rust/version.txt | 12 + .../goldens/cli/upstream/help-all.txt | 1766 ++++++++++++ .../goldens/cli/upstream/version.txt | 25 + docs/deployment/README.md | 223 ++ .../examples/aria2-rust-pro.service | 18 + docs/deployment/examples/aria2.conf | 20 + docs/migration/README.md | 188 ++ docs/perf/README.md | 21 + docs/perf/criterion-benchmarks.md | 178 ++ docs/perf/local-comparison.md | 89 + docs/perf/optimization-ranking.md | 283 ++ docs/perf/rpc-pressure-evidence.md | 82 + docs/perf/size-evidence.md | 137 + docs/project-origins.md | 22 + docs/release/README.md | 305 +++ docs/release/RELEASE-NOTES-TEMPLATE.md | 69 + docs/release/v1.0.0.md | 56 + docs/testing/quality-gates.md | 147 + renovate.json | 29 + rust-toolchain.toml | 3 + rustfmt.toml | 7 + scripts/ci/bootstrap-rust.sh | 83 + scripts/ci/check-conventional-commits.sh | 53 + scripts/ci/install-cargo-tools.sh | 80 + scripts/ci/install-linux-deps.sh | 43 + scripts/ci/publish-gitea-release.sh | 167 ++ scripts/ci/run-fast-gates.sh | 22 + scripts/ci/run-semver-checks.sh | 54 + scripts/ci/run-strict-gates.sh | 79 + scripts/compat/capture-cli-goldens.ps1 | 33 + scripts/docker/export-local.ps1 | 32 + scripts/docker/smoke-local.ps1 | 19 + scripts/docker/smoke.ps1 | 36 + scripts/perf/collect_local_comparison.ps1 | 45 + .../profile_shared_runtime_http_pressure.ps1 | 83 + ...ile_shared_runtime_http_pressure_admin.ps1 | 49 + scripts/perf/run_admin_command.ps1 | 57 + scripts/perf/run_admin_probe.ps1 | 54 + scripts/release/package-local.ps1 | 41 + scripts/release/smoke-version.ps1 | 33 + scripts/testing/strict-sweep.ps1 | 26 + xtask/Cargo.toml | 31 + xtask/src/cli.rs | 237 ++ xtask/src/compat.rs | 168 ++ xtask/src/docker.rs | 991 +++++++ xtask/src/main.rs | 68 + xtask/src/perf.rs | 1622 +++++++++++ xtask/src/release.rs | 614 +++++ xtask/src/testing.rs | 172 ++ xtask/src/workspace.rs | 239 ++ 321 files changed, 76859 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cliff.toml create mode 100644 crates/aria2-rust-pro-cli/Cargo.toml create mode 100644 crates/aria2-rust-pro-cli/src/args.rs create mode 100644 crates/aria2-rust-pro-cli/src/http_runtime.rs create mode 100644 crates/aria2-rust-pro-cli/src/http_runtime/build.rs create mode 100644 crates/aria2-rust-pro-cli/src/http_runtime/execution.rs create mode 100644 crates/aria2-rust-pro-cli/src/http_runtime/persist.rs create mode 100644 crates/aria2-rust-pro-cli/src/http_runtime/planning.rs create mode 100644 crates/aria2-rust-pro-cli/src/lib.rs create mode 100644 crates/aria2-rust-pro-cli/src/main.rs create mode 100644 crates/aria2-rust-pro-cli/src/parallel_http_runtime.rs create mode 100644 crates/aria2-rust-pro-cli/src/projection.rs create mode 100644 crates/aria2-rust-pro-cli/src/rpc_daemon.rs create mode 100644 crates/aria2-rust-pro-cli/src/runtime_execution.rs create mode 100644 crates/aria2-rust-pro-cli/src/runtime_host.rs create mode 100644 crates/aria2-rust-pro-cli/src/runtime_planning.rs create mode 100644 crates/aria2-rust-pro-cli/src/runtime_summary.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/command_surface.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/bt_runtime_and_tracker.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/cli_overrides_and_proxy.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/config_and_protocol_surface.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/http_runtime_and_checksum.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/input_file_and_source_order.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/integration_surface/rpc_pressure_and_command_surface.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution/checksum_completion.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution/live_http_connector.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution/retry_and_partial.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_parallelism.rs create mode 100644 crates/aria2-rust-pro-cli/src/tests/runtime_execution/segment_planning.rs create mode 100644 crates/aria2-rust-pro-cli/src/transfer_resolution.rs create mode 100644 crates/aria2-rust-pro-cli/src/transfer_runtime.rs create mode 100644 crates/aria2-rust-pro-cli/src/types.rs create mode 100644 crates/aria2-rust-pro-compat/Cargo.toml create mode 100644 crates/aria2-rust-pro-compat/src/compat.rs create mode 100644 crates/aria2-rust-pro-compat/src/config.rs create mode 100644 crates/aria2-rust-pro-compat/src/help.rs create mode 100644 crates/aria2-rust-pro-compat/src/lib.rs create mode 100644 crates/aria2-rust-pro-compat/src/options.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/model.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/query.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/registry.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/registry/compatibility_extension_entries.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/registry/foundational_entries.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/registry/hooks_and_rpc_entries.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/registry/transfer_tuning_entries.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/reserved.rs create mode 100644 crates/aria2-rust-pro-compat/src/options/tests.rs create mode 100644 crates/aria2-rust-pro-core/Cargo.toml create mode 100644 crates/aria2-rust-pro-core/src/engine.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/bt_runtime.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/inspection.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/queue.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/scheduling.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/session_persistence.rs create mode 100644 crates/aria2-rust-pro-core/src/engine/tests.rs create mode 100644 crates/aria2-rust-pro-core/src/error.rs create mode 100644 crates/aria2-rust-pro-core/src/events.rs create mode 100644 crates/aria2-rust-pro-core/src/lib.rs create mode 100644 crates/aria2-rust-pro-core/src/options.rs create mode 100644 crates/aria2-rust-pro-core/src/piece.rs create mode 100644 crates/aria2-rust-pro-core/src/progress.rs create mode 100644 crates/aria2-rust-pro-core/src/request.rs create mode 100644 crates/aria2-rust-pro-core/src/request/bt.rs create mode 100644 crates/aria2-rust-pro-core/src/request/context.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group/bt_peers.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group/bt_pieces.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group/bt_share.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group/model.rs create mode 100644 crates/aria2-rust-pro-core/src/request/group/state.rs create mode 100644 crates/aria2-rust-pro-core/src/request/identity.rs create mode 100644 crates/aria2-rust-pro-core/src/request/request_tests.rs create mode 100644 crates/aria2-rust-pro-core/src/request/segment.rs create mode 100644 crates/aria2-rust-pro-core/src/runtime.rs create mode 100644 crates/aria2-rust-pro-core/src/scheduler.rs create mode 100644 crates/aria2-rust-pro-core/src/scheduler/scheduler_tests.rs create mode 100644 crates/aria2-rust-pro-core/src/session.rs create mode 100644 crates/aria2-rust-pro-protocol/Cargo.toml create mode 100644 crates/aria2-rust-pro-protocol/src/auth.rs create mode 100644 crates/aria2-rust-pro-protocol/src/bt.rs create mode 100644 crates/aria2-rust-pro-protocol/src/bt_metalink.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader/contracts.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader/core_downloader.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader/downloader_tests.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader/fixture_downloader.rs create mode 100644 crates/aria2-rust-pro-protocol/src/downloader/reqwest_connector.rs create mode 100644 crates/aria2-rust-pro-protocol/src/ftp.rs create mode 100644 crates/aria2-rust-pro-protocol/src/http.rs create mode 100644 crates/aria2-rust-pro-protocol/src/http/checksum.rs create mode 100644 crates/aria2-rust-pro-protocol/src/http/model.rs create mode 100644 crates/aria2-rust-pro-protocol/src/http/progress.rs create mode 100644 crates/aria2-rust-pro-protocol/src/http/tests.rs create mode 100644 crates/aria2-rust-pro-protocol/src/lib.rs create mode 100644 crates/aria2-rust-pro-protocol/src/magnet.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink/model.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink/normalization.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink/parser.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink/planner.rs create mode 100644 crates/aria2-rust-pro-protocol/src/metalink/tests.rs create mode 100644 crates/aria2-rust-pro-protocol/src/session.rs create mode 100644 crates/aria2-rust-pro-protocol/src/sftp.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/bencode.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/dht.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/dht/codec.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/dht/compact.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/dht/message.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/metadata.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/model.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/peer_wire.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/peer_wire/extension.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/peer_wire/framing.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/peer_wire/handshake.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/tests.rs create mode 100644 crates/aria2-rust-pro-protocol/src/torrent/utils.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/error.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/parsing.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/request_response.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/reqwest_transport.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/tracker_tests.rs create mode 100644 crates/aria2-rust-pro-protocol/src/tracker/udp.rs create mode 100644 crates/aria2-rust-pro-protocol/src/transport.rs create mode 100644 crates/aria2-rust-pro-protocol/src/transport/model.rs create mode 100644 crates/aria2-rust-pro-protocol/src/transport/std_connectors.rs create mode 100644 crates/aria2-rust-pro-protocol/src/transport/tests.rs create mode 100644 crates/aria2-rust-pro-rpc/Cargo.toml create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/dht.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/peer_wire.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/reporting.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/runtime_state.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/bt_runtime/tracker.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/bt_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/dht.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/peer_wire.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/rpc_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/selection.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/compat_support/tracker.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/dispatch_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/faults.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/helpers.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/mutations.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/payloads.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/queries.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/bt_status_and_magnet.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/dht_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/extensions_and_multicall.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/peer_wire_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/bt_and_extension/tracker_and_bridges.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/protocol_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/additions_and_state.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/options_and_files.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_and_uri.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/file_views.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_mutation.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/queue_views.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/session_and_shutdown.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/queue_views_and_transfer/transfer_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/tests/queue_and_options/status_and_global.rs create mode 100644 crates/aria2-rust-pro-rpc/src/dispatcher/transfer_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/handlers.rs create mode 100644 crates/aria2-rust-pro-rpc/src/jsonrpc.rs create mode 100644 crates/aria2-rust-pro-rpc/src/lib.rs create mode 100644 crates/aria2-rust-pro-rpc/src/methods.rs create mode 100644 crates/aria2-rust-pro-rpc/src/model.rs create mode 100644 crates/aria2-rust-pro-rpc/src/router.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/config.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/http_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/tests.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/tests/http_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/tests/websocket_dispatch.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/tests/websocket_handshake.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/tests/websocket_session.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/transport_runtime.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/websocket_dispatch.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/websocket_handshake.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/websocket_session.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/websocket_surface.rs create mode 100644 crates/aria2-rust-pro-rpc/src/server/websocket_wire.rs create mode 100644 crates/aria2-rust-pro-rpc/src/session.rs create mode 100644 crates/aria2-rust-pro-rpc/src/websocket.rs create mode 100644 crates/aria2-rust-pro-rpc/src/websocket/bridge.rs create mode 100644 crates/aria2-rust-pro-rpc/src/websocket/notification.rs create mode 100644 crates/aria2-rust-pro-rpc/src/websocket/registry.rs create mode 100644 crates/aria2-rust-pro-rpc/src/websocket/tests.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc/codec.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc/convert.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc/model.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc/scanner.rs create mode 100644 crates/aria2-rust-pro-rpc/src/xmlrpc/tests.rs create mode 100644 crates/aria2-rust-pro-storage/Cargo.toml create mode 100644 crates/aria2-rust-pro-storage/src/allocation.rs create mode 100644 crates/aria2-rust-pro-storage/src/cache.rs create mode 100644 crates/aria2-rust-pro-storage/src/checksum.rs create mode 100644 crates/aria2-rust-pro-storage/src/control.rs create mode 100644 crates/aria2-rust-pro-storage/src/control/binary.rs create mode 100644 crates/aria2-rust-pro-storage/src/control/model.rs create mode 100644 crates/aria2-rust-pro-storage/src/control/tests.rs create mode 100644 crates/aria2-rust-pro-storage/src/control/text.rs create mode 100644 crates/aria2-rust-pro-storage/src/disk.rs create mode 100644 crates/aria2-rust-pro-storage/src/io.rs create mode 100644 crates/aria2-rust-pro-storage/src/lib.rs create mode 100644 crates/aria2-rust-pro-storage/src/model.rs create mode 100644 crates/aria2-rust-pro-storage/src/resume.rs create mode 100644 crates/aria2-rust-pro-storage/src/session.rs create mode 100644 crates/aria2-rust-pro-storage/src/store.rs create mode 100644 crates/aria2-rust-pro-tests/Cargo.toml create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure.rs create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure/bt_visibility.rs create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure/live_http_transfer.rs create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure/rpc_runtime_pressure.rs create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure/runtime_engine_pressure.rs create mode 100644 crates/aria2-rust-pro-tests/benches/rpc_pressure/support.rs create mode 100644 crates/aria2-rust-pro-tests/src/lib.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/bt_status_and_selection.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/dht_and_peer_wire.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/foundations_and_protocol.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/rpc_parity.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/rpc_pressure_and_runtime.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/support.rs create mode 100644 crates/aria2-rust-pro-tests/src/tests/tracker_and_surface_regression.rs create mode 100644 crates/aria2-rust-pro-tests/test_support/support.rs create mode 100644 crates/aria2-rust-pro-tests/tests/bt_cli_runtime.rs create mode 100644 crates/aria2-rust-pro-tests/tests/bt_magnet_promotion.rs create mode 100644 crates/aria2-rust-pro-tests/tests/bt_orchestration.rs create mode 100644 deny.toml create mode 100644 docker/.dockerignore create mode 100644 docker/.env.example create mode 100644 docker/Dockerfile create mode 100644 docker/defaults/aria2.conf create mode 100644 docker/defaults/bt-tracker.txt create mode 100644 docker/defaults/script.conf create mode 100644 docker/defaults/script/clean.sh create mode 100644 docker/defaults/script/delete.sh create mode 100644 docker/defaults/script/move.sh create mode 100644 docker/defaults/script/rclone.env create mode 100644 docker/defaults/script/tracker.sh create mode 100644 docker/defaults/script/upload.sh create mode 100644 docker/docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100644 docs/architecture/adr/0001-rust-native-rewrite.md create mode 100644 docs/compatibility/aria2-compat-ledger.md create mode 100644 docs/compatibility/goldens/README.md create mode 100644 docs/compatibility/goldens/cli/manifest.json create mode 100644 docs/compatibility/goldens/cli/rust/help-all.txt create mode 100644 docs/compatibility/goldens/cli/rust/version.txt create mode 100644 docs/compatibility/goldens/cli/upstream/help-all.txt create mode 100644 docs/compatibility/goldens/cli/upstream/version.txt create mode 100644 docs/deployment/README.md create mode 100644 docs/deployment/examples/aria2-rust-pro.service create mode 100644 docs/deployment/examples/aria2.conf create mode 100644 docs/migration/README.md create mode 100644 docs/perf/README.md create mode 100644 docs/perf/criterion-benchmarks.md create mode 100644 docs/perf/local-comparison.md create mode 100644 docs/perf/optimization-ranking.md create mode 100644 docs/perf/rpc-pressure-evidence.md create mode 100644 docs/perf/size-evidence.md create mode 100644 docs/project-origins.md create mode 100644 docs/release/README.md create mode 100644 docs/release/RELEASE-NOTES-TEMPLATE.md create mode 100644 docs/release/v1.0.0.md create mode 100644 docs/testing/quality-gates.md create mode 100644 renovate.json create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100755 scripts/ci/bootstrap-rust.sh create mode 100755 scripts/ci/check-conventional-commits.sh create mode 100755 scripts/ci/install-cargo-tools.sh create mode 100755 scripts/ci/install-linux-deps.sh create mode 100755 scripts/ci/publish-gitea-release.sh create mode 100755 scripts/ci/run-fast-gates.sh create mode 100755 scripts/ci/run-semver-checks.sh create mode 100755 scripts/ci/run-strict-gates.sh create mode 100644 scripts/compat/capture-cli-goldens.ps1 create mode 100644 scripts/docker/export-local.ps1 create mode 100644 scripts/docker/smoke-local.ps1 create mode 100644 scripts/docker/smoke.ps1 create mode 100644 scripts/perf/collect_local_comparison.ps1 create mode 100644 scripts/perf/profile_shared_runtime_http_pressure.ps1 create mode 100644 scripts/perf/profile_shared_runtime_http_pressure_admin.ps1 create mode 100644 scripts/perf/run_admin_command.ps1 create mode 100644 scripts/perf/run_admin_probe.ps1 create mode 100644 scripts/release/package-local.ps1 create mode 100644 scripts/release/smoke-version.ps1 create mode 100644 scripts/testing/strict-sweep.ps1 create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/cli.rs create mode 100644 xtask/src/compat.rs create mode 100644 xtask/src/docker.rs create mode 100644 xtask/src/main.rs create mode 100644 xtask/src/perf.rs create mode 100644 xtask/src/release.rs create mode 100644 xtask/src/testing.rs create mode 100644 xtask/src/workspace.rs 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..acf5036 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,134 @@ +# Contributing to aria2-rust-pro + +Thanks for contributing to `aria2-rust-pro`. + +This repository is maintained as a practical, compatibility-driven Rust 2024 +implementation informed by [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) +and the upstream [aria2 project](https://github.com/aria2/aria2). The most +helpful contributions improve behavior, compatibility evidence, packaging, and +maintainability without drifting the external aria2-facing contract. + +## Development Setup + +Recommended local prerequisites: + +- Rust toolchain from `rust-toolchain.toml` +- PowerShell 7 on Windows +- Docker Desktop or a compatible Docker daemon for image validation +- `rtk` for compact command output and the repository's preferred terminal flow + +From the repository root: + +```powershell +rtk cargo metadata --manifest-path .\Cargo.toml --no-deps --format-version 1 +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +``` + +## Preferred Workflow + +1. branch from `main` +2. keep the edit scope tight and purpose-driven +3. follow existing crate boundaries instead of inventing new cross-cutting + abstractions too early +4. add or update tests when behavior changes +5. run the relevant gates before asking for review + +If your change affects release packaging or container behavior, also run the +owned smoke path: + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local +``` + +## Required Quality Gates + +The expected final gates are documented in +[docs/testing/quality-gates.md](docs/testing/quality-gates.md). The usual +closeout set is: + +```powershell +rtk cargo fmt --all --check +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +rtk cargo clippy --workspace --all-targets --all-features --locked --no-deps -- -D warnings -D clippy::pedantic -D clippy::nursery -D clippy::cargo +rtk cargo +nightly udeps --workspace --all-targets --all-features --locked +rtk cargo deny --locked check +``` + +For a one-shot local sweep: + +```powershell +pwsh ./scripts/testing/strict-sweep.ps1 +``` + +## Repository Conventions + +- Prefer Cargo-native `xtask` entrypoints over ad hoc scripts when both exist. +- Keep public-facing behavior compatible unless the change is an intentional, + documented divergence. +- Preserve release evidence: manifests, checksums, version-smoke validation, + and migration notes matter here. +- Prefer `rtk` commands for repo work. +- Keep documentation in sync when changing deployment, Docker, migration, or + packaging behavior. + +### Commit Messages + +Use Conventional Commits for every authored commit: + +```text +type(scope)!: concise summary +``` + +Allowed types are `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `build`, +`ci`, `chore`, and `revert`. The scope and `!` are optional. Use `!` or a +`BREAKING CHANGE:` footer for intentional compatibility breaks. CI validates +non-merge commits after the `v1.0.0` baseline. + +### Versioning and Changelog + +The workspace package version in the root `Cargo.toml` is the release version. +Internal crate constraints are centralized in `[workspace.dependencies]`; do +not add crate-local copies. Release tags remain `vMAJOR.MINOR.PATCH` and must +match the workspace version. + +- `feat` is a minor-release candidate. +- `fix` and `perf` are patch-release candidates. +- `!` or `BREAKING CHANGE:` requires a major-release decision. +- `docs`, `refactor`, `test`, `build`, `ci`, and `chore` do not independently + require a version bump. + +`git-cliff` generates user-facing entries from Conventional Commits. Run the +following preview before a release-facing change: + +```powershell +rtk git-cliff --config .\cliff.toml --unreleased +``` + +### Dependency Updates + +Renovate creates dependency pull requests against `main`. Patch updates may +automerge only after CI succeeds; minor and major updates always remain review +pull requests. Lockfile maintenance is also review-only. + +## Pull Requests and Reviews + +When opening a review: + +- summarize the behavior change in plain language +- list the exact verification commands you ran +- call out compatibility-sensitive areas explicitly +- mention any remaining limits or follow-up work instead of hiding them + +## Release-Facing Changes + +If your change affects users directly, also update at least one of: + +- [CHANGELOG.md](CHANGELOG.md) +- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md) +- [docs/migration/README.md](docs/migration/README.md) + +That keeps the repository ready for an actual external release instead of only +an internal development snapshot. 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..a5eb4e7 --- /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 aria2-compatible implementation informed by Aria2-Pro-Core" +edition = "2024" +keywords = ["aria2", "download", "rpc", "bittorrent", "rust"] +license = "GPL-2.0-or-later" +readme = "README.md" +rust-version = "1.88" +version = "1.0.0" + +[workspace.dependencies] +aria2-rust-pro-cli = { version = "1.0.0", path = "crates/aria2-rust-pro-cli" } +aria2-rust-pro-compat = { version = "1.0.0", path = "crates/aria2-rust-pro-compat" } +aria2-rust-pro-core = { version = "1.0.0", path = "crates/aria2-rust-pro-core" } +aria2-rust-pro-protocol = { version = "1.0.0", path = "crates/aria2-rust-pro-protocol" } +aria2-rust-pro-rpc = { version = "1.0.0", path = "crates/aria2-rust-pro-rpc" } +aria2-rust-pro-storage = { version = "1.0.0", path = "crates/aria2-rust-pro-storage" } + +[workspace.lints.rust] +elided_lifetimes_in_paths = "deny" +future_incompatible = { level = "deny", priority = -1 } +missing_copy_implementations = "deny" +missing_debug_implementations = "deny" +missing_docs = "deny" +rust_2018_idioms = { level = "deny", priority = -1 } +rust_2021_compatibility = { level = "deny", priority = -1 } +rust_2024_compatibility = { level = "deny", priority = -1 } +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unreachable_pub = "deny" +unsafe_op_in_unsafe_fn = "deny" +unsafe_code = "forbid" +unused = { level = "deny", priority = -1 } +unused_crate_dependencies = "deny" +unused_import_braces = "deny" +unused_lifetimes = "deny" +unused_qualifications = "deny" +warnings = "deny" + +[workspace.lints.rustdoc] +bare_urls = "deny" +broken_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +as_conversions = "deny" +arithmetic_side_effects = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_precision_loss = "deny" +cast_sign_loss = "deny" +dbg_macro = "deny" +float_arithmetic = "deny" +indexing_slicing = "deny" +integer_division = "deny" +missing_docs_in_private_items = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +missing_safety_doc = "deny" +multiple_unsafe_ops_per_block = "deny" +todo = "deny" +undocumented_unsafe_blocks = "deny" +unimplemented = "deny" + +[profile.release] +codegen-units = 1 +debug = 0 +debug-assertions = false +lto = "fat" +opt-level = "s" +panic = "abort" +strip = "symbols" 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..0e004c2 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# aria2-rust-pro + +`aria2-rust-pro` is an independently maintained Rust 2024 implementation of +an aria2-compatible download client. Its compatibility and migration work are +informed by [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) and the +upstream [aria2 project](https://github.com/aria2/aria2). + +This repository is the maintained Rust line for the project. It aims to keep +the familiar aria2 configuration and RPC surface, while moving the +implementation onto a modern Cargo-based toolchain, stricter quality gates, and +source-first packaging automation. + +## Project Origins + +- [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) is the direct + compatibility and migration reference for the enhanced deployment profile. +- [aria2](https://github.com/aria2/aria2) is the upstream project whose CLI, + configuration, and RPC contracts guide this implementation. + +See [Project Origins](docs/project-origins.md) for the scope of those +references and this project's independent maintenance model. + +## What This Repository Provides + +- a Rust-native `aria2-rust-pro` CLI with aria2-style config and RPC behavior +- a Docker image build that preserves the familiar Pro Docker environment model +- a Cargo-native `xtask` workflow for release packaging, Docker smoke tests, + and local export +- strict workspace-wide linting, testing, and dependency policy enforcement +- local release staging for Windows artifacts and portable Docker image tar + exports + +## Current Snapshot + +The repository already includes: + +- a local Windows release packaging flow +- a local Docker image export flow +- JSON-RPC and XML-RPC coverage for representative aria2 client paths +- BitTorrent, Metalink, HTTP, HTTPS, XML-RPC, JSON-RPC, and SFTP feature + surfaces in the current binary banner + +The current public repository state should be read as: + +- source of truth for ongoing Rust development +- suitable for local builds, local packaging, and local Docker deployment +- released as a source snapshot under `v1.0.0`; binary and image assets are + built locally from the documented packaging workflows +- not yet presented as a public container registry or broad multi-platform + binary distribution service + +## Quick Start + +### Build the binary + +```powershell +rtk cargo build --release -p aria2-rust-pro-cli --bin aria2-rust-pro +``` + +### Check the version banner + +```powershell +.\target\release\aria2-rust-pro.exe --version +``` + +### Run the main validation lanes + +```powershell +rtk cargo check --workspace --all-targets --all-features --locked +rtk cargo nextest run --workspace --all-targets --all-features --locked +``` + +### Package a local release artifact + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build +``` + +### Build and export a local Docker image tar + +```powershell +rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker export-local --build +``` + +## Repository Guide + +- [docs/deployment/README.md](docs/deployment/README.md): native and Docker + deployment guide +- [docs/migration/README.md](docs/migration/README.md): migration notes from + existing [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) and + Aria2-Pro-Docker installs +- [docs/release/README.md](docs/release/README.md): local release packaging, + Docker export, and artifact manifest flow +- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md): + release announcement template +- [docs/compatibility/aria2-compat-ledger.md](docs/compatibility/aria2-compat-ledger.md): + compatibility tracking ledger +- [docs/testing/quality-gates.md](docs/testing/quality-gates.md): required test + and lint gates +- [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow and repository + expectations +- [CHANGELOG.md](CHANGELOG.md): release-facing change history + +## Workspace Layout + +- `crates/aria2-rust-pro-cli`: CLI surface, config projection, runtime launch, + and rendering +- `crates/aria2-rust-pro-compat`: aria2-facing option and compatibility layer +- `crates/aria2-rust-pro-core`: scheduler, engine, runtime coordination +- `crates/aria2-rust-pro-protocol`: HTTP, BitTorrent, Metalink, and transport + domain logic +- `crates/aria2-rust-pro-rpc`: JSON-RPC, XML-RPC, routing, and handlers +- `crates/aria2-rust-pro-storage`: session and persistence layers +- `crates/aria2-rust-pro-tests`: integration, compatibility, and pressure tests +- `xtask/`: Cargo-native project automation +- `docker/`: image build, entrypoint, compose example, and bundled defaults + +## Packaging and Local Artifacts + +Local staged artifacts live under: + +- `dist/release/v/` 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](https://github.com/P3TERX/Aria2-Pro-Core) + and Aria2-Pro-Docker +- release packaging that keeps runtime evidence with the artifact + +## License + +`aria2-rust-pro` is licensed under [GPL-2.0-or-later](LICENSE). OpenSSL +linking-exception handling remains a release and documentation concern carried +forward from the aria2 ecosystem. 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..122fa9f --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,188 @@ +# 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 migrating from the [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) + host binary +- 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 public `v1.0.0` release is a source snapshot; local packaging commands produce host-specific artifacts | 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](https://github.com/P3TERX/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..2b2db05 --- /dev/null +++ b/docs/perf/criterion-benchmarks.md @@ -0,0 +1,178 @@ +# 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 upstream [aria2](https://github.com/aria2/aria2) + reference and the [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) + reference baseline +- 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 +- upstream aria2 / Aria2-Pro-Core reference-baseline 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..42f51ea --- /dev/null +++ b/docs/perf/local-comparison.md @@ -0,0 +1,89 @@ +# 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 + +- Reference projects: [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) + and [aria2](https://github.com/aria2/aria2) +- 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 +- Aria2-Pro-Core reference executable: \build\pro-core\windows-x64-mingw-30403\stage\aria2-pro-core-1.37.0+pro.20260523-windows-x64-mingw\aria2c.exe +- Aria2-Pro-Core reference 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 across Rust, the Aria2-Pro-Core reference baseline, and 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/project-origins.md b/docs/project-origins.md new file mode 100644 index 0000000..761e686 --- /dev/null +++ b/docs/project-origins.md @@ -0,0 +1,22 @@ +# Project Origins + +`aria2-rust-pro` is an independently maintained Rust implementation. It uses +the following projects as explicit compatibility and migration references. + +## Aria2-Pro-Core + +[Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) is the direct +reference for the enhanced deployment profile and migration expectations that +this project supports. Its behavior informs compatibility work; it is not this +repository's release source. + +## aria2 + +[aria2](https://github.com/aria2/aria2) is the upstream download utility whose +CLI, configuration, RPC, and protocol contracts guide this implementation. + +## Relationship + +This repository has its own Rust codebase, release process, and issue tracker. +Compatibility claims describe the intended user-facing behavior; they do not +make this repository an official release of either reference project. 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..f68729a --- /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 + +- [aria2](https://github.com/aria2/aria2) baseline: +- [Aria2-Pro-Core](https://github.com/P3TERX/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..b36b421 --- /dev/null +++ b/docs/release/v1.0.0.md @@ -0,0 +1,56 @@ +# aria2-rust-pro 1.0.0 + +`aria2-rust-pro 1.0.0` is the first public source release of this independently +maintained Rust implementation. + +This release establishes a versioned source-release baseline for the project: + +- a public Git tag for the Rust 2024 workspace +- Cargo-native local packaging and Docker export workflows +- repository-local migration, deployment, and compatibility documentation + +## Project Sources + +- [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) is the direct + compatibility and migration reference for the enhanced deployment profile. +- [aria2](https://github.com/aria2/aria2) is the upstream project whose public + CLI, configuration, and RPC contracts guide this implementation. + +## Highlights + +- independently maintained Rust 2024 implementation informed by those sources +- Cargo-native release packaging and Docker export automation +- compatibility-led CLI, configuration, JSON-RPC, and XML-RPC development + +## 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 source release intentionally has no prebuilt binary or Docker image +assets. Use the documented local packaging workflows to produce +`dist/release/v1.0.0/` or `dist/docker/v1.0.0/` artifacts for a target host. + +## Verification + +The repository release workflow is intended to perform: + +- 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 + +- prebuilt binary and Docker image assets are not attached to this release +- 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..cede147 --- /dev/null +++ b/xtask/src/perf.rs @@ -0,0 +1,1622 @@ +//! 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!( + "- Aria2-Pro-Core reference executable: {}", + path.display() + )); + if let Some(summary) = &references.pro_core_version_summary { + lines.push(format!( + "- Aria2-Pro-Core reference version summary: {summary}" + )); + } + } else { + lines.push( + "- Aria2-Pro-Core reference 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 across Rust, the Aria2-Pro-Core reference baseline, and 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()) +}