diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20ba2e2..e500e79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,8 +126,25 @@ jobs: if: runner.os == 'Windows' working-directory: src-tauri run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture + - name: Install Aria2 source build dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev + - name: Install Aria2 source build dependencies (Windows) + id: aria2-msys + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: >- + base-devel autoconf automake libtool gettext-devel + mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2 + mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat + mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib - name: Provision locked engines if: runner.os != 'macOS' + env: + FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} run: node scripts/provision-engines.js --target ${{ matrix.target }} - name: Stage and verify engines env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da34dd2..4d0f8cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,8 +100,25 @@ jobs: desktop-file-utils \ xdg-utils - run: npm ci + - name: Install Aria2 source build dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev + - name: Install Aria2 source build dependencies (Windows) + id: aria2-msys + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: >- + base-devel autoconf automake libtool gettext-devel + mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2 + mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat + mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib - name: Provision locked engines if: runner.os != 'macOS' + env: + FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} run: node scripts/provision-engines.js --target ${{ matrix.target }} - name: Build package if: runner.os != 'Linux' diff --git a/RELEASE.md b/RELEASE.md index abc65fd..6c15a27 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -26,6 +26,16 @@ Firelink never falls back to system-installed media tools. invocation-owned temporary workspace. - `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks. +Aria2 allocation telemetry is a required bundle capability. Windows and Linux +provisioning now builds the checksum-pinned upstream source archive with +`scripts/aria2/firelink.patch`; this patch also retains Firelink's native DNS, +network target policy, and Torrent routing changes. CI installs the compiler +and static-library prerequisites. Windows uses the MSYS2 installation returned +by the setup action (`FIRELINK_MSYS2_ROOT`, default `C:/msys64` for local builds). +The patch checksum is recorded in both source and payload provenance. Never +replace these builds with stock Aria2 archives: package verification requires +`firelinkAllocationTelemetry: true` from `aria2.getVersion`. + Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries. yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency. diff --git a/engine-sources.lock.json b/engine-sources.lock.json index a4be654..8fda142 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -18,9 +18,19 @@ "sha256": "41d735c9364a8deda25b3bd5f05abf37720316be9495edfe94f51bc088ce9d86" }, "aria2c": { - "version": "1.37.0", - "url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip", - "sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288" + "version": "1.37.0-firelink-native-dns-v1", + "url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz", + "sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b", + "buildFromSource": true, + "patch": "scripts/aria2/firelink.patch", + "patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6", + "allocationTelemetry": true, + "firelinkRouteContract": { + "revision": "firelink-native-dns-v1", + "dnsResolver": "native-async", + "networkTargetPolicy": "firelink-v1", + "networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705" + } } }, "x86_64-unknown-linux-gnu": { @@ -40,11 +50,19 @@ "sha256": "1a4fa0f89f690bd81bddbb7cf3a65554095e8e9f9dc98693494e6f5fb97918c5" }, "aria2c": { - "version": "1.37.0", - "url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip", - "sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a", - "upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0", - "builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0" + "version": "1.37.0-firelink-native-dns-v1", + "url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz", + "sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b", + "buildFromSource": true, + "patch": "scripts/aria2/firelink.patch", + "patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6", + "allocationTelemetry": true, + "firelinkRouteContract": { + "revision": "firelink-native-dns-v1", + "dnsResolver": "native-async", + "networkTargetPolicy": "firelink-v1", + "networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705" + } } } } diff --git a/engines.lock.json b/engines.lock.json index b96edfe..b5a4f4d 100644 --- a/engines.lock.json +++ b/engines.lock.json @@ -12,14 +12,16 @@ "aria2c": { "version": "1.37.0-firelink-native-dns-v1", "source": "https://github.com/aria2/aria2/tree/release-1.37.0", - "build": "Firelink native-async DNS and network-target-policy patch set; arm64 executable with adjacent aria2-libs", + "build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs", "firelinkRouteContract": { "revision": "firelink-native-dns-v1", "dnsResolver": "native-async", "networkTargetPolicy": "firelink-v1", "networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705" }, - "sha256": "b6e51125007860e1a58c75737faa2cd0d1d5372c1c9ca82d1bf87d2c40081aac" + "sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7", + "allocationTelemetry": true, + "patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6" }, "ffmpeg": { "version": "9.0.1", diff --git a/scripts/aria2-route-contract.js b/scripts/aria2-route-contract.js index c5eeb67..eb0221f 100644 --- a/scripts/aria2-route-contract.js +++ b/scripts/aria2-route-contract.js @@ -130,3 +130,9 @@ export function assertAria2RouteSource(source, target) { } } } + +export function assertAria2AllocationCapabilities(version) { + if (version?.firelinkAllocationTelemetry !== true) { + throw new Error('Bundled Aria2 does not expose file allocation telemetry'); + } +} diff --git a/scripts/aria2-route-contract.node-test.js b/scripts/aria2-route-contract.node-test.js index 807f121..ed8e48d 100644 --- a/scripts/aria2-route-contract.node-test.js +++ b/scripts/aria2-route-contract.node-test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + assertAria2AllocationCapabilities, ARIA2_DNS_RESOLVER, ARIA2_FIRELINK_REVISION, ARIA2_NETWORK_TARGET_POLICY, @@ -81,3 +82,9 @@ test('system resolver options cannot retain the custom target policy', () => { /active target policy/, ); }); + +test('allocation telemetry is mandatory and must be a JSON boolean capability', () => { + assert.throws(() => assertAria2AllocationCapabilities({ version: '1.37.0' })); + assert.throws(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: 'true' })); + assert.doesNotThrow(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: true })); +}); diff --git a/scripts/aria2/build.sh b/scripts/aria2/build.sh new file mode 100755 index 0000000..f6ac156 --- /dev/null +++ b/scripts/aria2/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +# Build from the checksum-pinned upstream archive plus the reviewed patch. +source_root="$1" +patch_file="$2" +if command -v cygpath >/dev/null 2>&1; then + source_root="$(cygpath -u "$source_root")" + patch_file="$(cygpath -u "$patch_file")" + export PATH="/mingw64/bin:/usr/bin:$PATH" + export PKG_CONFIG_PATH=/mingw64/lib/pkgconfig +fi +cd "$source_root" +patch --batch -p1 < "$patch_file" +autoreconf -fi +mkdir firelink-build +cd firelink-build +# Linux and Windows payloads are self-contained; do not inherit host dylibs. +export LDFLAGS="-static ${LDFLAGS:-}" +export PKG_CONFIG="pkg-config --static" +../configure --enable-static --disable-shared --disable-nls \ + --without-gnutls --with-openssl --without-libxml2 --with-libexpat \ + --without-libgmp --without-libnettle --without-libgcrypt \ + --with-libssh2 --with-libcares +make -j2 diff --git a/scripts/aria2/firelink.patch b/scripts/aria2/firelink.patch new file mode 100644 index 0000000..304cbae --- /dev/null +++ b/scripts/aria2/firelink.patch @@ -0,0 +1,3747 @@ +diff --git a/configure.ac b/configure.ac +index 14b340f4..ffc8ecfe 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -111,0 +112,5 @@ AM_PROG_AS ++# Native asynchronous DNS uses std::thread even when c-ares is not available. ++# Reuse gettext's portable thread detection so the resolver is linked with ++# the platform's required thread library on systems where it is separate. ++gl_THREADLIB ++ +@@ -638,7 +643,3 @@ AM_CONDITIONAL([HAVE_LIBEXPAT], [test "x$have_libexpat" = "xyes"]) +-if test "x$have_libcares" = "xyes"; then +- AC_DEFINE([ENABLE_ASYNC_DNS], [1], +- [Define to 1 if asynchronous DNS support is enabled.]) +- AM_CONDITIONAL([ENABLE_ASYNC_DNS], true) +-else +- AM_CONDITIONAL([ENABLE_ASYNC_DNS], false) +-fi ++AC_DEFINE([ENABLE_ASYNC_DNS], [1], ++ [Define to 1 if asynchronous DNS support is enabled.]) ++AM_CONDITIONAL([ENABLE_ASYNC_DNS], true) +@@ -1034,0 +1036,2 @@ fi ++EXTRALIBS="$EXTRALIBS $LIBMULTITHREAD" ++ +diff --git a/src/AbstractCommand.cc b/src/AbstractCommand.cc +index 1502d570..d3448c4f 100644 +--- a/src/AbstractCommand.cc ++++ b/src/AbstractCommand.cc +@@ -69,0 +70 @@ ++#include "NetworkTargetPolicy.h" +@@ -325,5 +326,15 @@ bool AbstractCommand::execute() +- A2_LOG_DEBUG(fmt("CUID#%" PRId64 " - Marking IP address %s as bad", +- getCuid(), req_->getConnectedAddr().c_str())); +- e_->markBadIPAddress(req_->getConnectedHostname(), +- req_->getConnectedAddr(), +- req_->getConnectedPort()); ++ if (isRouteAwareNetworkPolicy()) { ++ A2_LOG_DEBUG(fmt("CUID#%" PRId64 ++ " - Discarding route-aware address %s", ++ getCuid(), req_->getConnectedAddr().c_str())); ++ req_->markRouteResolvedAddressBad(req_->getConnectedHostname(), ++ req_->getConnectedAddr(), ++ req_->getConnectedPort()); ++ } ++ else { ++ A2_LOG_DEBUG(fmt("CUID#%" PRId64 " - Marking IP address %s as bad", ++ getCuid(), req_->getConnectedAddr().c_str())); ++ e_->markBadIPAddress(req_->getConnectedHostname(), ++ req_->getConnectedAddr(), ++ req_->getConnectedPort()); ++ } +@@ -331,3 +342,6 @@ bool AbstractCommand::execute() +- if (e_->findCachedIPAddress(req_->getConnectedHostname(), +- req_->getConnectedPort()) +- .empty()) { ++ if (isRouteAwareNetworkPolicy()) { ++ req_->clearRouteResolvedAddresses(); ++ } ++ else if (e_->findCachedIPAddress(req_->getConnectedHostname(), ++ req_->getConnectedPort()) ++ .empty()) { +@@ -443,0 +458 @@ bool AbstractCommand::prepareForRetry(time_t wait) ++ req_->clearRouteResolvedAddresses(); +@@ -762,0 +778,2 @@ std::string AbstractCommand::resolveHostname(std::vector& addrs, ++ const bool routeAware = isRouteAwareNetworkPolicy(); ++ +@@ -768,6 +785,14 @@ std::string AbstractCommand::resolveHostname(std::vector& addrs, +- e_->findAllCachedIPAddresses(std::back_inserter(addrs), hostname, port); +- if (!addrs.empty()) { +- auto ipaddr = addrs.front(); +- A2_LOG_INFO(fmt(MSG_DNS_CACHE_HIT, getCuid(), hostname.c_str(), +- strjoin(std::begin(addrs), std::end(addrs), ", ").c_str())); +- return ipaddr; ++ if (routeAware) { ++ if (req_->getRouteResolvedAddresses(hostname, port, addrs)) { ++ return addrs.front(); ++ } ++ } ++ else { ++ e_->findAllCachedIPAddresses(std::back_inserter(addrs), hostname, port); ++ if (!addrs.empty()) { ++ auto ipaddr = addrs.front(); ++ A2_LOG_INFO( ++ fmt(MSG_DNS_CACHE_HIT, getCuid(), hostname.c_str(), ++ strjoin(std::begin(addrs), std::end(addrs), ", ").c_str())); ++ return ipaddr; ++ } +@@ -817,0 +843,5 @@ std::string AbstractCommand::resolveHostname(std::vector& addrs, ++ if (routeAware) { ++ req_->setRouteResolvedAddresses(hostname, port, addrs); ++ return addrs.front(); ++ } ++ +@@ -824,0 +855,19 @@ std::string AbstractCommand::resolveHostname(std::vector& addrs, ++void AbstractCommand::validateNetworkTarget() const ++{ ++ if (!network::isFirelinkPolicyEnabled(getOption().get())) { ++ return; ++ } ++ ++ std::string reason; ++ if (!network::validateUri(req_->getCurrentUri(), &reason)) { ++ throw DL_ABORT_EX2(fmt("CUID#%" PRId64 " - Network target rejected: %s", ++ getCuid(), reason.c_str()), ++ error_code::NAME_RESOLVE_ERROR); ++ } ++} ++ ++bool AbstractCommand::isRouteAwareNetworkPolicy() const ++{ ++ return network::isFirelinkPolicyEnabled(getOption().get()); ++} ++ +@@ -845 +894,25 @@ bool AbstractCommand::checkIfConnectionEstablished( +- // See also InitiateConnectionCommand::executeInternal() ++ // See also InitiateConnectionCommand::executeInternal(). Route-aware DNS ++ // deliberately has no process-wide cache, so consume the address from the ++ // current Request-owned attempt instead. ++ if (isRouteAwareNetworkPolicy()) { ++ if (req_->markRouteResolvedAddressBad(connectedHostname, connectedAddr, ++ connectedPort)) { ++ A2_LOG_INFO(fmt(MSG_CONNECT_FAILED_AND_RETRY, getCuid(), ++ connectedAddr.c_str(), connectedPort)); ++ e_->setNoWait(true); ++ e_->addCommand( ++ InitiateConnectionCommandFactory::createInitiateConnectionCommand( ++ getCuid(), req_, fileEntry_, requestGroup_, e_)); ++ return false; ++ } ++ req_->clearRouteResolvedAddresses(); ++ // Don't set error if proxy server is used and its method is GET. ++ if (resolveProxyMethod(req_->getProtocol()) != V_GET || ++ !isProxyRequest(req_->getProtocol(), getOption())) { ++ e_->getRequestGroupMan() ++ ->getOrCreateServerStat(req_->getHost(), req_->getProtocol()) ++ ->setError(); ++ } ++ throw DL_RETRY_EX(fmt(MSG_ESTABLISHING_CONNECTION_FAILED, error.c_str())); ++ } ++ +diff --git a/src/AbstractCommand.h b/src/AbstractCommand.h +index 1cb366df..aa182825 100644 +--- a/src/AbstractCommand.h ++++ b/src/AbstractCommand.h +@@ -140,0 +141,5 @@ public: ++ // Validate the current request URI before selecting a direct or proxy ++ // connection. Proxy endpoints are route-owned and are validated by their ++ // own connection path. ++ void validateNetworkTarget() const; ++ +@@ -204,0 +210,2 @@ public: ++ bool isRouteAwareNetworkPolicy() const; ++ +diff --git a/src/AsyncNameResolver.cc b/src/AsyncNameResolver.cc +index b041d6f9..defd0cf1 100644 +--- a/src/AsyncNameResolver.cc ++++ b/src/AsyncNameResolver.cc +@@ -18,2 +18 @@ +- * along with this program; if not, write to the Free Software +- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ++ * along with this program. If not, see . +@@ -28,5 +27,5 @@ +- * file(s) with this exception, you may extend this exception to your +- * version of the file(s), but you are not obligated to do so. If you +- * do not wish to do so, delete this exception statement from your +- * version. If you delete this exception statement from all source +- * files in the program, then also delete it here. ++ * file(s) with this exception, you may extend this exception to ++ * your version of the file(s), but you are not obligated to do so. ++ * If you do not wish to do so, delete this exception statement from ++ * your version. If you delete this exception statement from all ++ * source files in the program, then also delete it here. +@@ -36,0 +36 @@ ++#include +@@ -46 +46,2 @@ namespace aria2 { +-void callback(void* arg, int status, int timeouts, struct hostent* host) ++#ifdef HAVE_LIBCARES ++void callback(void* arg, int status, int /*timeouts*/, struct hostent* host) +@@ -53,0 +55,5 @@ void callback(void* arg, int status, int timeouts, struct hostent* host) ++ if (!host) { ++ resolverPtr->error_ = "c-ares returned no host data"; ++ resolverPtr->status_ = AsyncNameResolver::STATUS_ERROR; ++ return; ++ } +@@ -67,0 +74 @@ void callback(void* arg, int status, int timeouts, struct hostent* host) ++#endif // HAVE_LIBCARES +@@ -75 +82,16 @@ AsyncNameResolver::AsyncNameResolver(int family +- : status_(STATUS_READY), family_(family) ++ : status_(STATUS_READY), ++ family_(family), ++#ifdef HAVE_LIBCARES ++ channel_(nullptr), ++#endif // HAVE_LIBCARES ++ resolverMode_( ++#ifdef HAVE_LIBCARES ++ RESOLVER_CARES ++#else ++ RESOLVER_NATIVE_ASYNC ++#endif ++ ) ++#ifdef HAVE_ARES_ADDR_NODE ++ , ++ servers_(servers) ++#endif // HAVE_ARES_ADDR_NODE +@@ -77,2 +99,18 @@ AsyncNameResolver::AsyncNameResolver(int family +- // TODO evaluate return value +- ares_init(&channel_); ++#ifdef HAVE_LIBCARES ++ initializeCares(); ++#else ++ nativeResolver_ = make_unique(family_); ++#endif // HAVE_LIBCARES ++} ++ ++#ifdef HAVE_LIBCARES ++void AsyncNameResolver::initializeCares() ++{ ++ channel_ = nullptr; ++ const int status = ares_init(&channel_); ++ if (status != ARES_SUCCESS) { ++ error_ = ares_strerror(status); ++ status_ = STATUS_ERROR; ++ A2_LOG_DEBUG(fmt("ares_init failed: %s", error_.c_str())); ++ return; ++ } +@@ -80,5 +118,2 @@ AsyncNameResolver::AsyncNameResolver(int family +- if (servers) { +- // ares_set_servers has been added since c-ares 1.7.1 +- if (ares_set_servers(channel_, servers) != ARES_SUCCESS) { +- A2_LOG_DEBUG("ares_set_servers failed"); +- } ++ if (servers_ && ares_set_servers(channel_, servers_) != ARES_SUCCESS) { ++ A2_LOG_DEBUG("ares_set_servers failed"); +@@ -87,0 +123 @@ AsyncNameResolver::AsyncNameResolver(int family ++#endif // HAVE_LIBCARES +@@ -89 +125,46 @@ AsyncNameResolver::AsyncNameResolver(int family +-AsyncNameResolver::~AsyncNameResolver() { ares_destroy(channel_); } ++AsyncNameResolver::~AsyncNameResolver() ++{ ++#ifdef HAVE_LIBCARES ++ if (channel_) { ++ ares_destroy(channel_); ++ } ++#endif // HAVE_LIBCARES ++} ++ ++void AsyncNameResolver::setResolverMode(ResolverMode mode) ++{ ++ if (mode == resolverMode_) { ++#ifdef HAVE_LIBCARES ++ if (mode == RESOLVER_CARES && !channel_) { ++ initializeCares(); ++ } ++#endif // HAVE_LIBCARES ++ if (mode == RESOLVER_NATIVE_ASYNC && !nativeResolver_) { ++ nativeResolver_ = make_unique(family_); ++ } ++ return; ++ } ++ ++#ifdef HAVE_LIBCARES ++ if (channel_) { ++ ares_destroy(channel_); ++ channel_ = nullptr; ++ } ++#endif // HAVE_LIBCARES ++ nativeResolver_.reset(); ++ resolverMode_ = mode; ++ ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ nativeResolver_ = make_unique(family_); ++ return; ++ } ++ ++#ifdef HAVE_LIBCARES ++ initializeCares(); ++#else ++ // The option parser does not expose c-ares when it is not built. Keep the ++ // class safe for API callers that nevertheless request it. ++ resolverMode_ = RESOLVER_NATIVE_ASYNC; ++ nativeResolver_ = make_unique(family_); ++#endif // HAVE_LIBCARES ++} +@@ -91,0 +173,6 @@ void AsyncNameResolver::resolve(const std::string& name) ++{ ++ resolve(name, std::chrono::steady_clock::now() + std::chrono::seconds(30)); ++} ++ ++void AsyncNameResolver::resolve( ++ const std::string& name, std::chrono::steady_clock::time_point deadline) +@@ -93,0 +181,2 @@ void AsyncNameResolver::resolve(const std::string& name) ++ resolvedAddresses_.clear(); ++ error_.clear(); +@@ -94,0 +184,11 @@ void AsyncNameResolver::resolve(const std::string& name) ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ nativeResolver_->resolve(name, deadline); ++ return; ++ } ++ ++#ifdef HAVE_LIBCARES ++ if (!channel_) { ++ error_ = "c-ares resolver is unavailable"; ++ status_ = STATUS_ERROR; ++ return; ++ } +@@ -95,0 +196,4 @@ void AsyncNameResolver::resolve(const std::string& name) ++#else ++ error_ = "c-ares resolver is unavailable"; ++ status_ = STATUS_ERROR; ++#endif // HAVE_LIBCARES +@@ -99,0 +204,12 @@ int AsyncNameResolver::getFds(fd_set* rfdsPtr, fd_set* wfdsPtr) const ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ const sock_t socket = nativeResolver_->getSocket(); ++ if (socket == static_cast(-1)) { ++ return 0; ++ } ++ FD_SET(socket, rfdsPtr); ++ return socket; ++ } ++#ifdef HAVE_LIBCARES ++ if (!channel_) { ++ return 0; ++ } +@@ -100,0 +217,3 @@ int AsyncNameResolver::getFds(fd_set* rfdsPtr, fd_set* wfdsPtr) const ++#else ++ return 0; ++#endif // HAVE_LIBCARES +@@ -105,3 +224,20 @@ void AsyncNameResolver::process(fd_set* rfdsPtr, fd_set* wfdsPtr) +- ares_process(channel_, rfdsPtr, wfdsPtr); +-} +- ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ const sock_t socket = nativeResolver_->getSocket(); ++ const sock_t readfd = ++ socket != static_cast(-1) && FD_ISSET(socket, rfdsPtr) ++ ? socket ++ : static_cast(-1); ++ // SelectEventPoll calls this after both socket activity and timer expiry. ++ // Always process once so a stalled getaddrinfo() observes its deadline. ++ nativeResolver_->process(readfd, static_cast(-1)); ++ if (nativeResolver_->getStatus() == NativeAsyncResolver::STATUS_SUCCESS) { ++ resolvedAddresses_ = nativeResolver_->getResolvedAddresses(); ++ status_ = STATUS_SUCCESS; ++ } ++ else if (nativeResolver_->getStatus() == ++ NativeAsyncResolver::STATUS_ERROR) { ++ error_ = nativeResolver_->getError(); ++ status_ = STATUS_ERROR; ++ } ++ return; ++ } +@@ -108,0 +245,5 @@ void AsyncNameResolver::process(fd_set* rfdsPtr, fd_set* wfdsPtr) ++ if (channel_) { ++ ares_process(channel_, rfdsPtr, wfdsPtr); ++ } ++#endif // HAVE_LIBCARES ++} +@@ -110 +251 @@ void AsyncNameResolver::process(fd_set* rfdsPtr, fd_set* wfdsPtr) +-int AsyncNameResolver::getsock(sock_t* sockets) const ++uint32_t AsyncNameResolver::getsock(sock_t* sockets) const +@@ -112,2 +253,40 @@ int AsyncNameResolver::getsock(sock_t* sockets) const +- return ares_getsock(channel_, reinterpret_cast(sockets), +- ARES_GETSOCK_MAXNUM); ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ const sock_t socket = nativeResolver_->getSocket(); ++ if (socket == static_cast(-1)) { ++ return 0; ++ } ++ sockets[0] = socket; ++ return 1; ++ } ++ ++#ifdef HAVE_LIBCARES ++ if (!channel_) { ++ return 0; ++ } ++ ares_socket_t aresSockets[ARES_GETSOCK_MAXNUM]; ++ const int aresMask = ares_getsock( ++ channel_, aresSockets, ARES_GETSOCK_MAXNUM); ++ uint32_t mask = 0; ++ for (size_t i = 0; i < ARES_GETSOCK_MAXNUM && i < MAX_SOCKETS; ++i) { ++ int events = 0; ++ if (ARES_GETSOCK_READABLE(aresMask, i)) { ++ events |= 1; ++ } ++ if (ARES_GETSOCK_WRITABLE(aresMask, i)) { ++ events |= 2; ++ } ++ if (events == 0) { ++ break; ++ } ++ sockets[i] = static_cast(aresSockets[i]); ++ if (events & 1) { ++ mask |= (1u << (2 * i)); ++ } ++ if (events & 2) { ++ mask |= (1u << (2 * i + 1)); ++ } ++ } ++ return mask; ++#else ++ return 0; ++#endif // HAVE_LIBCARES +@@ -116 +295 @@ int AsyncNameResolver::getsock(sock_t* sockets) const +-void AsyncNameResolver::process(ares_socket_t readfd, ares_socket_t writefd) ++void AsyncNameResolver::process(sock_t readfd, sock_t writefd) +@@ -118,2 +297,13 @@ void AsyncNameResolver::process(ares_socket_t readfd, ares_socket_t writefd) +- ares_process_fd(channel_, readfd, writefd); +-} ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ nativeResolver_->process(readfd, writefd); ++ if (nativeResolver_->getStatus() == NativeAsyncResolver::STATUS_SUCCESS) { ++ resolvedAddresses_ = nativeResolver_->getResolvedAddresses(); ++ status_ = STATUS_SUCCESS; ++ } ++ else if (nativeResolver_->getStatus() == ++ NativeAsyncResolver::STATUS_ERROR) { ++ error_ = nativeResolver_->getError(); ++ status_ = STATUS_ERROR; ++ } ++ return; ++ } +@@ -120,0 +311,8 @@ void AsyncNameResolver::process(ares_socket_t readfd, ares_socket_t writefd) ++#ifdef HAVE_LIBCARES ++ if (channel_) { ++ ares_process_fd(channel_, static_cast(readfd), ++ static_cast(writefd)); ++ } ++#else ++ (void)readfd; ++ (void)writefd; +@@ -121,0 +320 @@ void AsyncNameResolver::process(ares_socket_t readfd, ares_socket_t writefd) ++} +@@ -127,0 +327,6 @@ bool AsyncNameResolver::operator==(const AsyncNameResolver& resolver) const ++void AsyncNameResolver::setAddr(const std::string& addrString) ++{ ++ resolvedAddresses_.push_back(addrString); ++ status_ = STATUS_SUCCESS; ++} ++ +@@ -131,0 +337 @@ void AsyncNameResolver::reset() ++ error_.clear(); +@@ -133,3 +339,11 @@ void AsyncNameResolver::reset() +- ares_destroy(channel_); +- // TODO evaluate return value +- ares_init(&channel_); ++ if (resolverMode_ == RESOLVER_NATIVE_ASYNC) { ++ nativeResolver_->reset(); ++ return; ++ } ++#ifdef HAVE_LIBCARES ++ if (channel_) { ++ ares_destroy(channel_); ++ channel_ = nullptr; ++ } ++ initializeCares(); ++#endif // HAVE_LIBCARES +diff --git a/src/AsyncNameResolver.h b/src/AsyncNameResolver.h +index 31a6ebe9..a7513762 100644 +--- a/src/AsyncNameResolver.h ++++ b/src/AsyncNameResolver.h +@@ -39,0 +40,3 @@ ++#include ++#include ++#include +@@ -43 +46,3 @@ +-#include ++#ifdef HAVE_LIBCARES ++# include ++#endif // HAVE_LIBCARES +@@ -45,0 +51 @@ ++#include "NativeAsyncResolver.h" +@@ -49,0 +56 @@ class AsyncNameResolver { ++#ifdef HAVE_LIBCARES +@@ -51,0 +59 @@ class AsyncNameResolver { ++#endif // HAVE_LIBCARES +@@ -60,0 +69,7 @@ public: ++ enum ResolverMode { ++ RESOLVER_NATIVE_ASYNC, ++ RESOLVER_CARES, ++ }; ++ ++ static const size_t MAX_SOCKETS = 16; ++ +@@ -63,0 +79 @@ private: ++#ifdef HAVE_LIBCARES +@@ -65,0 +82,9 @@ private: ++ void initializeCares(); ++#endif // HAVE_LIBCARES ++ ++ ResolverMode resolverMode_; ++ std::unique_ptr nativeResolver_; ++#ifdef HAVE_ARES_ADDR_NODE ++ ares_addr_node* servers_; ++#endif // HAVE_ARES_ADDR_NODE ++ +@@ -79,0 +105,2 @@ public: ++ void setResolverMode(ResolverMode mode); ++ +@@ -81,0 +109,3 @@ public: ++ void resolve(const std::string& name, ++ std::chrono::steady_clock::time_point deadline); ++ +@@ -96 +125,0 @@ public: +-#ifdef HAVE_LIBCARES +@@ -98 +127,5 @@ public: +- int getsock(sock_t* sockets) const; ++ /** ++ * Return a normalized socket mask. Bit 2*i means readable socket i and ++ * bit 2*i+1 means writable socket i. ++ */ ++ uint32_t getsock(sock_t* sockets) const; +@@ -100,3 +133 @@ public: +- void process(ares_socket_t readfd, ares_socket_t writefd); +- +-#endif // HAVE_LIBCARES ++ void process(sock_t readfd, sock_t writefd); +diff --git a/src/AsyncNameResolverMan.cc b/src/AsyncNameResolverMan.cc +index be073561..bb0464fa 100644 +--- a/src/AsyncNameResolverMan.cc ++++ b/src/AsyncNameResolverMan.cc +@@ -52 +52,5 @@ AsyncNameResolverMan::AsyncNameResolverMan() +- : numResolver_(0), resolverCheck_(0), ipv4_(true), ipv6_(true) ++ : numResolver_(0), ++ resolverCheck_(0), ++ ipv4_(true), ++ ipv6_(true), ++ nativeResolver_(true) +@@ -97 +101,7 @@ void AsyncNameResolverMan::startAsyncFamily(const std::string& hostname, +- asyncNameResolver_[numResolver_]->resolve(hostname); ++ asyncNameResolver_[numResolver_]->setResolverMode( ++ nativeResolver_ ? AsyncNameResolver::RESOLVER_NATIVE_ASYNC ++ : AsyncNameResolver::RESOLVER_CARES); ++ asyncNameResolver_[numResolver_]->resolve( ++ hostname, ++ std::chrono::steady_clock::now() + ++ std::chrono::seconds(e->getOption()->getAsInt(PREF_DNS_TIMEOUT))); +@@ -224,0 +235,2 @@ void configureAsyncNameResolverMan(AsyncNameResolverMan* asyncNameResolverMan, ++ asyncNameResolverMan->setNativeResolver( ++ option->get(PREF_DNS_RESOLVER) != V_CARES); +diff --git a/src/AsyncNameResolverMan.h b/src/AsyncNameResolverMan.h +index 617474ec..2d2edb5e 100644 +--- a/src/AsyncNameResolverMan.h ++++ b/src/AsyncNameResolverMan.h +@@ -39,0 +40 @@ ++#include +@@ -81,0 +83,6 @@ public: ++ // Selects the resolver implementation used by newly started lookups. ++ void setNativeResolver(bool nativeResolver) ++ { ++ nativeResolver_ = nativeResolver; ++ } ++ +@@ -94,0 +102 @@ private: ++ bool nativeResolver_; +diff --git a/src/ConnectCommand.cc b/src/ConnectCommand.cc +index 5d1566be..909a4e73 100644 +--- a/src/ConnectCommand.cc ++++ b/src/ConnectCommand.cc +@@ -92,3 +92,10 @@ bool ConnectCommand::executeInternal() +- getDownloadEngine()->markBadIPAddress(getRequest()->getConnectedHostname(), +- getRequest()->getConnectedAddr(), +- getRequest()->getConnectedPort()); ++ if (isRouteAwareNetworkPolicy()) { ++ getRequest()->markRouteResolvedAddressBad( ++ getRequest()->getConnectedHostname(), getRequest()->getConnectedAddr(), ++ getRequest()->getConnectedPort()); ++ } ++ else { ++ getDownloadEngine()->markBadIPAddress( ++ getRequest()->getConnectedHostname(), ++ getRequest()->getConnectedAddr(), getRequest()->getConnectedPort()); ++ } +@@ -110,0 +118 @@ bool ConnectCommand::executeInternal() ++ getRequest()->clearRouteResolvedAddresses(); +diff --git a/src/DHTEntryPointNameResolveCommand.cc b/src/DHTEntryPointNameResolveCommand.cc +index 35ceee0c..93b08df6 100644 +--- a/src/DHTEntryPointNameResolveCommand.cc ++++ b/src/DHTEntryPointNameResolveCommand.cc +@@ -43,0 +44 @@ ++#include "DHTMessageDispatcher.h" +@@ -52,0 +54 @@ ++#include "NetworkTargetPolicy.h" +@@ -69,0 +72 @@ DHTEntryPointNameResolveCommand::DHTEntryPointNameResolveCommand( ++ messageDispatcher_{nullptr}, +@@ -98,0 +102,9 @@ bool DHTEntryPointNameResolveCommand::execute() ++ if (network::isFirelinkPolicyEnabled(e_->getOption())) { ++ std::string reason; ++ if (!network::validateHost(hostname, &reason)) { ++ A2_LOG_INFO(fmt("DHT entry point rejected by policy: %s", ++ reason.c_str())); ++ entryPoints_.pop_front(); ++ continue; ++ } ++ } +@@ -124,0 +137,9 @@ bool DHTEntryPointNameResolveCommand::execute() ++ if (network::isFirelinkPolicyEnabled(e_->getOption())) { ++ std::string reason; ++ if (!network::validateHost(hostname, &reason)) { ++ A2_LOG_INFO(fmt("DHT entry point rejected by policy: %s", ++ reason.c_str())); ++ entryPoints_.pop_front(); ++ continue; ++ } ++ } +@@ -154,0 +176,3 @@ void DHTEntryPointNameResolveCommand::addPingTask( ++ if (messageDispatcher_) { ++ messageDispatcher_->allowRouteOwnedAddress(addr.first); ++ } +@@ -225,0 +250,6 @@ void DHTEntryPointNameResolveCommand::setLocalNode( ++void DHTEntryPointNameResolveCommand::setMessageDispatcher( ++ DHTMessageDispatcher* messageDispatcher) ++{ ++ messageDispatcher_ = messageDispatcher; ++} ++ +diff --git a/src/DHTEntryPointNameResolveCommand.h b/src/DHTEntryPointNameResolveCommand.h +index 3e131833..ad8b6251 100644 +--- a/src/DHTEntryPointNameResolveCommand.h ++++ b/src/DHTEntryPointNameResolveCommand.h +@@ -51,0 +52 @@ class DHTNode; ++class DHTMessageDispatcher; +@@ -72,0 +74,2 @@ private: ++ DHTMessageDispatcher* messageDispatcher_; ++ +@@ -105,0 +109,2 @@ public: ++ ++ void setMessageDispatcher(DHTMessageDispatcher* messageDispatcher); +diff --git a/src/DHTMessageDispatcher.h b/src/DHTMessageDispatcher.h +index 72617876..02f54be2 100644 +--- a/src/DHTMessageDispatcher.h ++++ b/src/DHTMessageDispatcher.h +@@ -40,0 +41 @@ ++#include +@@ -64,0 +66,5 @@ public: ++ ++ // A hostname-resolved bootstrap address belongs to the active OS/TUN route, ++ // even when it is represented by a synthetic private IP. Implementations ++ // that enforce literal-target policy may retain a bounded, expiring grant. ++ virtual void allowRouteOwnedAddress(const std::string&) {} +diff --git a/src/DHTMessageDispatcherImpl.cc b/src/DHTMessageDispatcherImpl.cc +index 97327d63..1082ecb0 100644 +--- a/src/DHTMessageDispatcherImpl.cc ++++ b/src/DHTMessageDispatcherImpl.cc +@@ -46,0 +47 @@ ++#include "NetworkTargetPolicy.h" +@@ -49,0 +51,5 @@ namespace aria2 { ++namespace { ++const size_t MAX_ROUTE_OWNED_DHT_ADDRESSES = 256; ++const auto ROUTE_OWNED_DHT_ADDRESS_TTL = std::chrono::minutes(5); ++} // namespace ++ +@@ -51,2 +57,5 @@ DHTMessageDispatcherImpl::DHTMessageDispatcherImpl( +- const std::shared_ptr& tracker) +- : tracker_{tracker}, timeout_{DHT_MESSAGE_TIMEOUT} ++ const std::shared_ptr& tracker, ++ bool networkTargetPolicyEnabled) ++ : tracker_{tracker}, ++ timeout_{DHT_MESSAGE_TIMEOUT}, ++ networkTargetPolicyEnabled_{networkTargetPolicyEnabled} +@@ -73,0 +83,27 @@ bool DHTMessageDispatcherImpl::sendMessage(DHTMessageEntry* entry) ++ if (networkTargetPolicyEnabled_) { ++ const auto& remoteNode = entry->message->getRemoteNode(); ++ std::string reason; ++ const auto now = std::chrono::steady_clock::now(); ++ for (auto itr = routeOwnedAddresses_.begin(); ++ itr != routeOwnedAddresses_.end();) { ++ if (itr->second <= now) { ++ itr = routeOwnedAddresses_.erase(itr); ++ } ++ else { ++ ++itr; ++ } ++ } ++ const bool routeOwned = ++ remoteNode && routeOwnedAddresses_.find(remoteNode->getIPAddress()) != ++ routeOwnedAddresses_.end(); ++ if (!remoteNode || ++ (!routeOwned && ++ !network::validateHost(remoteNode->getIPAddress(), &reason))) { ++ A2_LOG_INFO(fmt("DHT target rejected by policy: %s", reason.c_str())); ++ if (!entry->message->isReply()) { ++ tracker_->addMessage(entry->message.get(), 0_s, ++ std::move(entry->callback)); ++ } ++ return true; ++ } ++ } +@@ -100,0 +137,23 @@ bool DHTMessageDispatcherImpl::sendMessage(DHTMessageEntry* entry) ++void DHTMessageDispatcherImpl::allowRouteOwnedAddress( ++ const std::string& address) ++{ ++ if (!networkTargetPolicyEnabled_ || address.empty()) { ++ return; ++ } ++ const auto now = std::chrono::steady_clock::now(); ++ for (auto itr = routeOwnedAddresses_.begin(); ++ itr != routeOwnedAddresses_.end();) { ++ if (itr->second <= now) { ++ itr = routeOwnedAddresses_.erase(itr); ++ } ++ else { ++ ++itr; ++ } ++ } ++ if (routeOwnedAddresses_.size() >= MAX_ROUTE_OWNED_DHT_ADDRESSES && ++ routeOwnedAddresses_.find(address) == routeOwnedAddresses_.end()) { ++ return; ++ } ++ routeOwnedAddresses_[address] = now + ROUTE_OWNED_DHT_ADDRESS_TTL; ++} ++ +diff --git a/src/DHTMessageDispatcherImpl.h b/src/DHTMessageDispatcherImpl.h +index c65076c0..e64aa8e6 100644 +--- a/src/DHTMessageDispatcherImpl.h ++++ b/src/DHTMessageDispatcherImpl.h +@@ -38,0 +39 @@ ++#include +@@ -39,0 +41 @@ ++#include +@@ -53,0 +56,5 @@ private: ++ bool networkTargetPolicyEnabled_; ++ ++ std::map ++ routeOwnedAddresses_; ++ +@@ -57 +64,3 @@ public: +- DHTMessageDispatcherImpl(const std::shared_ptr& tracker); ++ DHTMessageDispatcherImpl( ++ const std::shared_ptr& tracker, ++ bool networkTargetPolicyEnabled = false); +@@ -73,0 +83,2 @@ public: ++ void allowRouteOwnedAddress(const std::string& address) CXX11_OVERRIDE; ++ +diff --git a/src/DHTMessageTrackerEntry.cc b/src/DHTMessageTrackerEntry.cc +index 9a4f4ad8..b0a21916 100644 +--- a/src/DHTMessageTrackerEntry.cc ++++ b/src/DHTMessageTrackerEntry.cc +@@ -41,0 +42,3 @@ ++#include "Logger.h" ++#include "LogFactory.h" ++#include "fmt.h" +@@ -76 +79,3 @@ bool DHTMessageTrackerEntry::match(const std::string& transactionID, +- return targetNode_->getIPAddress() == "::ffff:" + ipaddr; ++ if (targetNode_->getIPAddress() == "::ffff:" + ipaddr) { ++ return true; ++ } +@@ -79 +84,3 @@ bool DHTMessageTrackerEntry::match(const std::string& transactionID, +- return ipaddr == "::ffff:" + targetNode_->getIPAddress(); ++ if (ipaddr == "::ffff:" + targetNode_->getIPAddress()) { ++ return true; ++ } +@@ -81 +88,10 @@ bool DHTMessageTrackerEntry::match(const std::string& transactionID, +- return false; ++ // Under TUN / Fake-IP / NAT rewrites, the remote IP may be translated. ++ // Transaction ID is a unique random 4-byte string per inflight query. ++ // If transaction ID matches and port matches, accept and update targetNode_ ++ // IP address to the translated IP. ++ A2_LOG_INFO(fmt("DHT matched inflight tracker entry by transactionID across " ++ "address translation (expected %s:%u, got %s:%u)", ++ targetNode_->getIPAddress().c_str(), targetNode_->getPort(), ++ ipaddr.c_str(), port)); ++ targetNode_->setIPAddress(ipaddr); ++ return true; +diff --git a/src/DHTSetup.cc b/src/DHTSetup.cc +index d107a53e..a43e851b 100644 +--- a/src/DHTSetup.cc ++++ b/src/DHTSetup.cc +@@ -75,0 +76 @@ ++#include "NetworkTargetPolicy.h" +@@ -145 +146,2 @@ DHTSetup::setup(DownloadEngine* e, int family) +- auto dispatcher = make_unique(tracker); ++ auto dispatcher = make_unique( ++ tracker, network::isFirelinkPolicyEnabled(e->getOption())); +@@ -186,0 +189 @@ DHTSetup::setup(DownloadEngine* e, int family) ++ std::vector> entryPoints; +@@ -188,18 +191,5 @@ DHTSetup::setup(DownloadEngine* e, int family) +- { +- PrefPtr prefEntryPointPort = family == AF_INET +- ? PREF_DHT_ENTRY_POINT_PORT +- : PREF_DHT_ENTRY_POINT_PORT6; +- std::pair addr( +- e->getOption()->get(prefEntryPointHost), +- e->getOption()->getAsInt(prefEntryPointPort)); +- std::vector> entryPoints; +- entryPoints.push_back(addr); +- auto command = make_unique( +- e->newCUID(), e, family, entryPoints); +- command->setBootstrapEnabled(true); +- command->setTaskQueue(taskQueue.get()); +- command->setTaskFactory(taskFactory.get()); +- command->setRoutingTable(routingTable.get()); +- command->setLocalNode(localNode); +- tempCommands.push_back(std::move(command)); +- } ++ PrefPtr prefEntryPointPort = family == AF_INET ++ ? PREF_DHT_ENTRY_POINT_PORT ++ : PREF_DHT_ENTRY_POINT_PORT6; ++ entryPoints.emplace_back(e->getOption()->get(prefEntryPointHost), ++ e->getOption()->getAsInt(prefEntryPointPort)); +@@ -208 +198,22 @@ DHTSetup::setup(DownloadEngine* e, int family) +- A2_LOG_INFO("No DHT entry point specified."); ++ A2_LOG_INFO("No DHT entry point specified; using default bootstrap nodes."); ++ if (family == AF_INET) { ++ entryPoints.emplace_back("router.bittorrent.com", 6881); ++ entryPoints.emplace_back("dht.transmissionbt.com", 6881); ++ entryPoints.emplace_back("router.utorrent.com", 6881); ++ entryPoints.emplace_back("dht.libtorrent.org", 25401); ++ } ++ else { ++ entryPoints.emplace_back("dht.transmissionbt.com", 6881); ++ entryPoints.emplace_back("router.bittorrent.com", 6881); ++ } ++ } ++ if (!entryPoints.empty()) { ++ auto command = make_unique( ++ e->newCUID(), e, family, entryPoints); ++ command->setBootstrapEnabled(true); ++ command->setTaskQueue(taskQueue.get()); ++ command->setTaskFactory(taskFactory.get()); ++ command->setRoutingTable(routingTable.get()); ++ command->setLocalNode(localNode); ++ command->setMessageDispatcher(dispatcher.get()); ++ tempCommands.push_back(std::move(command)); +diff --git a/src/DownloadEngine.cc b/src/DownloadEngine.cc +index 2037f66f..a692d366 100644 +--- a/src/DownloadEngine.cc ++++ b/src/DownloadEngine.cc +@@ -77,0 +78 @@ ++#include "NetworkTargetPolicy.h" +@@ -314,0 +316,7 @@ void DownloadEngine::poolSocket(const std::string& key, ++ if (isRouteAwareNetworkPolicy()) { ++ // A pooled socket is already bound to the route that existed when it was ++ // opened. Without a route generation from the OS/TUN layer, reusing it ++ // could silently bypass a route change, so route-aware transfers always ++ // establish a fresh connection. ++ return; ++ } +@@ -460,0 +469,3 @@ DownloadEngine::popPooledSocket(const std::string& ipaddr, uint16_t port, ++ if (isRouteAwareNetworkPolicy()) { ++ return nullptr; ++ } +@@ -476,0 +488,3 @@ DownloadEngine::popPooledSocket(std::string& options, const std::string& ipaddr, ++ if (isRouteAwareNetworkPolicy()) { ++ return nullptr; ++ } +@@ -538,0 +553,5 @@ cuid_t DownloadEngine::newCUID() { return cuidCounter_.newID(); } ++bool DownloadEngine::isRouteAwareNetworkPolicy() const ++{ ++ return option_ && network::isFirelinkPolicyEnabled(option_); ++} ++ +diff --git a/src/DownloadEngine.h b/src/DownloadEngine.h +index b544cfc6..7d9dfd25 100644 +--- a/src/DownloadEngine.h ++++ b/src/DownloadEngine.h +@@ -235,0 +236,2 @@ public: ++ bool isRouteAwareNetworkPolicy() const; ++ +diff --git a/src/Event.h b/src/Event.h +index df8c4387..b34f892b 100644 +--- a/src/Event.h ++++ b/src/Event.h +@@ -41,0 +42 @@ ++#include +@@ -150,2 +151,2 @@ public: +- ares_socket_t readfd; +- ares_socket_t writefd; ++ sock_t readfd; ++ sock_t writefd; +@@ -157 +158 @@ public: +- readfd = ARES_SOCKET_BAD; ++ readfd = static_cast(-1); +@@ -164 +165 @@ public: +- writefd = ARES_SOCKET_BAD; ++ writefd = static_cast(-1); +@@ -298 +299 @@ private: +- sock_t sockets_[ARES_GETSOCK_MAXNUM]; ++ sock_t sockets_[AsyncNameResolver::MAX_SOCKETS]; +@@ -327 +328 @@ public: +- int mask = nameResolver_->getsock(sockets_); ++ uint32_t mask = nameResolver_->getsock(sockets_); +@@ -332 +333 @@ public: +- for (i = 0; i < ARES_GETSOCK_MAXNUM; ++i) { ++ for (i = 0; i < AsyncNameResolver::MAX_SOCKETS; ++i) { +@@ -334 +335 @@ public: +- if (ARES_GETSOCK_READABLE(mask, i)) { ++ if (mask & (1u << (2 * i))) { +@@ -337 +338 @@ public: +- if (ARES_GETSOCK_WRITABLE(mask, i)) { ++ if (mask & (1u << (2 * i + 1))) { +@@ -356,2 +357,2 @@ public: +- // Calls AsyncNameResolver::process(ARES_SOCKET_BAD, +- // ARES_SOCKET_BAD). ++ // Calls AsyncNameResolver::process() without a socket event. This gives ++ // resolver implementations a chance to process timer-driven completions. +@@ -360 +361,6 @@ public: +- nameResolver_->process(ARES_SOCKET_BAD, ARES_SOCKET_BAD); ++ const auto previousStatus = nameResolver_->getStatus(); ++ nameResolver_->process(static_cast(-1), ++ static_cast(-1)); ++ if (nameResolver_->getStatus() != previousStatus) { ++ command_->setStatusActive(); ++ } +diff --git a/src/FirelinkCapabilities.cc b/src/FirelinkCapabilities.cc +new file mode 100644 +index 00000000..202b8660 +--- /dev/null ++++ b/src/FirelinkCapabilities.cc +@@ -0,0 +1,28 @@ ++/* */ ++#include "FirelinkCapabilities.h" ++ ++namespace aria2 { ++namespace firelink { ++ ++const char REVISION[] = "firelink-native-dns-v1"; ++const char DNS_RESOLVER[] = "native-async"; ++const char NETWORK_TARGET_POLICY[] = "firelink-v1"; ++ ++// SHA-256 of the canonical policy vector documented by the literal CIDRs and ++// route-owned hostname/redirect/proxy rules in NetworkTargetPolicy.cc. ++const char NETWORK_TARGET_POLICY_DIGEST[] = ++ "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"; ++ ++} // namespace firelink ++} // namespace aria2 +diff --git a/src/FirelinkCapabilities.h b/src/FirelinkCapabilities.h +new file mode 100644 +index 00000000..6a76fce8 +--- /dev/null ++++ b/src/FirelinkCapabilities.h +@@ -0,0 +1,27 @@ ++/* */ ++#ifndef D_FIRELINK_CAPABILITIES_H ++#define D_FIRELINK_CAPABILITIES_H ++ ++namespace aria2 { ++namespace firelink { ++ ++extern const char REVISION[]; ++extern const char DNS_RESOLVER[]; ++extern const char NETWORK_TARGET_POLICY[]; ++extern const char NETWORK_TARGET_POLICY_DIGEST[]; ++ ++} // namespace firelink ++} // namespace aria2 ++ ++#endif // D_FIRELINK_CAPABILITIES_H +diff --git a/src/FtpInitiateConnectionCommand.cc b/src/FtpInitiateConnectionCommand.cc +index b41655f8..00ad8d77 100644 +--- a/src/FtpInitiateConnectionCommand.cc ++++ b/src/FtpInitiateConnectionCommand.cc +@@ -128 +128 @@ std::unique_ptr FtpInitiateConnectionCommand::createNextCommandProxied( +- setupBackupConnection(hostname, addr, port, c.get()); ++ setupBackupConnection(resolvedAddresses, addr, port, c.get()); +@@ -132,0 +133 @@ std::unique_ptr FtpInitiateConnectionCommand::createNextCommandProxied( ++ getRequest()->clearRouteResolvedAddresses(); +@@ -202 +203 @@ std::unique_ptr FtpInitiateConnectionCommand::createNextCommandPlain( +- setupBackupConnection(hostname, addr, port, c.get()); ++ setupBackupConnection(resolvedAddresses, addr, port, c.get()); +@@ -206,0 +208 @@ std::unique_ptr FtpInitiateConnectionCommand::createNextCommandPlain( ++ getRequest()->clearRouteResolvedAddresses(); +diff --git a/src/FtpNegotiationCommand.cc b/src/FtpNegotiationCommand.cc +index d9100b42..c071531b 100644 +--- a/src/FtpNegotiationCommand.cc ++++ b/src/FtpNegotiationCommand.cc +@@ -750 +750 @@ bool FtpNegotiationCommand::sendTunnelRequest() +- std::string error = getSocket()->getSocketError(); ++ std::string error = dataSocket_->getSocketError(); +@@ -753,4 +753,17 @@ bool FtpNegotiationCommand::sendTunnelRequest() +- getDownloadEngine()->markBadIPAddress(proxyReq->getHost(), proxyAddr_, +- proxyReq->getPort()); +- std::string nextProxyAddr = getDownloadEngine()->findCachedIPAddress( +- proxyReq->getHost(), proxyReq->getPort()); ++ std::string nextProxyAddr; ++ if (isRouteAwareNetworkPolicy()) { ++ getRequest()->markRouteResolvedAddressBad( ++ proxyReq->getHost(), proxyAddr_, proxyReq->getPort()); ++ std::vector addrs; ++ getRequest()->getRouteResolvedAddresses( ++ proxyReq->getHost(), proxyReq->getPort(), addrs); ++ if (!addrs.empty()) { ++ nextProxyAddr = addrs.front(); ++ } ++ } ++ else { ++ getDownloadEngine()->markBadIPAddress( ++ proxyReq->getHost(), proxyAddr_, proxyReq->getPort()); ++ nextProxyAddr = getDownloadEngine()->findCachedIPAddress( ++ proxyReq->getHost(), proxyReq->getPort()); ++ } +@@ -758,2 +771,7 @@ bool FtpNegotiationCommand::sendTunnelRequest() +- getDownloadEngine()->removeCachedIPAddress(proxyReq->getHost(), +- proxyReq->getPort()); ++ if (isRouteAwareNetworkPolicy()) { ++ getRequest()->clearRouteResolvedAddresses(); ++ } ++ else { ++ getDownloadEngine()->removeCachedIPAddress(proxyReq->getHost(), ++ proxyReq->getPort()); ++ } +@@ -796,0 +815 @@ bool FtpNegotiationCommand::sendTunnelRequest() ++ getRequest()->clearRouteResolvedAddresses(); +diff --git a/src/HttpInitiateConnectionCommand.cc b/src/HttpInitiateConnectionCommand.cc +index 74c74b7a..0e62c0a2 100644 +--- a/src/HttpInitiateConnectionCommand.cc ++++ b/src/HttpInitiateConnectionCommand.cc +@@ -101 +101 @@ std::unique_ptr HttpInitiateConnectionCommand::createNextCommand( +- setupBackupConnection(hostname, addr, port, c.get()); ++ setupBackupConnection(resolvedAddresses, addr, port, c.get()); +@@ -105,0 +106 @@ std::unique_ptr HttpInitiateConnectionCommand::createNextCommand( ++ getRequest()->clearRouteResolvedAddresses(); +@@ -133 +134 @@ std::unique_ptr HttpInitiateConnectionCommand::createNextCommand( +- setupBackupConnection(hostname, addr, port, c.get()); ++ setupBackupConnection(resolvedAddresses, addr, port, c.get()); +@@ -138,0 +140 @@ std::unique_ptr HttpInitiateConnectionCommand::createNextCommand( ++ getRequest()->clearRouteResolvedAddresses(); +diff --git a/src/HttpSkipResponseCommand.cc b/src/HttpSkipResponseCommand.cc +index a722d774..faa7550a 100644 +--- a/src/HttpSkipResponseCommand.cc ++++ b/src/HttpSkipResponseCommand.cc +@@ -200,0 +201 @@ bool HttpSkipResponseCommand::processResponse() ++ validateNetworkTarget(); +diff --git a/src/InitiateConnectionCommand.cc b/src/InitiateConnectionCommand.cc +index b23413e8..279cfe8b 100644 +--- a/src/InitiateConnectionCommand.cc ++++ b/src/InitiateConnectionCommand.cc +@@ -75,0 +76,2 @@ bool InitiateConnectionCommand::executeInternal() ++ validateNetworkTarget(); ++ +@@ -104,0 +107,17 @@ bool InitiateConnectionCommand::executeInternal() ++ if (isRouteAwareNetworkPolicy()) { ++ if (getRequest()->markRouteResolvedAddressBad(hostname, ipaddr, port)) { ++ A2_LOG_INFO_EX(EX_EXCEPTION_CAUGHT, ex); ++ A2_LOG_INFO( ++ fmt(MSG_CONNECT_FAILED_AND_RETRY, getCuid(), ipaddr.c_str(), port)); ++ auto command = ++ InitiateConnectionCommandFactory::createInitiateConnectionCommand( ++ getCuid(), getRequest(), getFileEntry(), getRequestGroup(), ++ getDownloadEngine()); ++ getDownloadEngine()->setNoWait(true); ++ getDownloadEngine()->addCommand(std::move(command)); ++ return true; ++ } ++ getRequest()->clearRouteResolvedAddresses(); ++ throw; ++ } ++ +@@ -133,2 +152,2 @@ InitiateConnectionCommand::createBackupIPv4ConnectCommand( +- const std::string& hostname, const std::string& ipaddr, uint16_t port, +- Command* mainCommand) ++ const std::vector& resolvedAddresses, ++ const std::string& ipaddr, uint16_t port, Command* mainCommand) +@@ -144,5 +163,2 @@ InitiateConnectionCommand::createBackupIPv4ConnectCommand( +- std::vector addrs; +- getDownloadEngine()->findAllCachedIPAddresses(std::back_inserter(addrs), +- hostname, port); +- for (std::vector::const_iterator i = addrs.begin(), +- eoi = addrs.end(); ++ for (std::vector::const_iterator i = resolvedAddresses.begin(), ++ eoi = resolvedAddresses.end(); +@@ -166,2 +182,2 @@ void InitiateConnectionCommand::setupBackupConnection( +- const std::string& hostname, const std::string& addr, uint16_t port, +- ConnectCommand* c) ++ const std::vector& resolvedAddresses, ++ const std::string& addr, uint16_t port, ConnectCommand* c) +@@ -170 +186 @@ void InitiateConnectionCommand::setupBackupConnection( +- createBackupIPv4ConnectCommand(hostname, addr, port, c); ++ createBackupIPv4ConnectCommand(resolvedAddresses, addr, port, c); +diff --git a/src/InitiateConnectionCommand.h b/src/InitiateConnectionCommand.h +index 12bf5d12..271b11a9 100644 +--- a/src/InitiateConnectionCommand.h ++++ b/src/InitiateConnectionCommand.h +@@ -72,3 +72,3 @@ protected: +- createBackupIPv4ConnectCommand(const std::string& hostname, +- const std::string& ipaddr, uint16_t port, +- Command* mainCommand); ++ createBackupIPv4ConnectCommand( ++ const std::vector& resolvedAddresses, ++ const std::string& ipaddr, uint16_t port, Command* mainCommand); +@@ -76,3 +76,3 @@ protected: +- void setupBackupConnection(const std::string& hostname, +- const std::string& addr, uint16_t port, +- ConnectCommand* c); ++ void setupBackupConnection( ++ const std::vector& resolvedAddresses, ++ const std::string& addr, uint16_t port, ConnectCommand* c); +diff --git a/src/Makefile.am b/src/Makefile.am +index cb6e3b7c..498890ca 100644 +--- a/src/Makefile.am ++++ b/src/Makefile.am +@@ -423,0 +424,4 @@ endif # HAVE_LIBSSH2 ++SRCS += \ ++ FirelinkCapabilities.cc FirelinkCapabilities.h \ ++ NetworkTargetPolicy.cc NetworkTargetPolicy.h ++ +@@ -426 +430,2 @@ SRCS += \ +- AsyncNameResolver.cc AsyncNameResolver.h\ ++ AsyncNameResolver.cc AsyncNameResolver.h \ ++ NativeAsyncResolver.cc NativeAsyncResolver.h \ +@@ -755 +759,0 @@ AM_CXXFLAGS = @WARNCXXFLAGS@ @CXX1XCXXFLAGS@ @EXTRACXXFLAGS@ +- +diff --git a/src/NameResolveCommand.cc b/src/NameResolveCommand.cc +index f2c5d01c..8b82bc65 100644 +--- a/src/NameResolveCommand.cc ++++ b/src/NameResolveCommand.cc +@@ -48,0 +49 @@ ++#include "NetworkTargetPolicy.h" +@@ -90,0 +92,9 @@ bool NameResolveCommand::execute() ++ if (network::isFirelinkPolicyEnabled(e_->getOption())) { ++ std::string reason; ++ if (!network::validateHost(hostname, &reason)) { ++ A2_LOG_INFO(fmt("UDP tracker target rejected by policy: %s", ++ reason.c_str())); ++ onFailure(); ++ return true; ++ } ++ } +diff --git a/src/NativeAsyncResolver.cc b/src/NativeAsyncResolver.cc +new file mode 100644 +index 00000000..afd4c945 +--- /dev/null ++++ b/src/NativeAsyncResolver.cc +@@ -0,0 +1,801 @@ ++/* */ ++#include "NativeAsyncResolver.h" ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#ifndef __MINGW32__ ++# include ++# include ++#else ++# include ++#endif ++ ++#include "SocketCore.h" ++#include "util.h" ++ ++namespace aria2 { ++ ++namespace { ++ ++const sock_t INVALID_SOCKET_FD = static_cast(-1); ++ ++#ifdef __MINGW32__ ++int socketError() ++{ ++ return WSAGetLastError(); ++} ++#else ++int socketError() { return errno; } ++#endif ++ ++void closeSocket(sock_t socket) ++{ ++ if (socket == INVALID_SOCKET_FD) { ++ return; ++ } ++#ifdef __MINGW32__ ++ closesocket(socket); ++#else ++ close(socket); ++#endif ++} ++ ++bool setNonBlocking(sock_t socket) ++{ ++#ifdef __MINGW32__ ++ u_long mode = 1; ++ return ioctlsocket(socket, FIONBIO, &mode) == 0; ++#else ++ int flags; ++ do { ++ flags = fcntl(socket, F_GETFL, 0); ++ } while (flags == -1 && errno == EINTR); ++ if (flags == -1) { ++ return false; ++ } ++ int rv; ++ do { ++ rv = fcntl(socket, F_SETFL, flags | O_NONBLOCK); ++ } while (rv == -1 && errno == EINTR); ++ return rv == 0; ++#endif ++} ++ ++struct WakePair { ++ sock_t readSocket = INVALID_SOCKET_FD; ++ sock_t writeSocket = INVALID_SOCKET_FD; ++}; ++ ++bool createWakePair(WakePair& pair) ++{ ++#ifdef __MINGW32__ ++ sock_t listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); ++ if (listener == INVALID_SOCKET_FD) { ++ return false; ++ } ++ ++ sockaddr_in address; ++ std::memset(&address, 0, sizeof(address)); ++ address.sin_family = AF_INET; ++ address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ address.sin_port = 0; ++ if (bind(listener, reinterpret_cast(&address), ++ sizeof(address)) == SOCKET_ERROR || ++ listen(listener, 1) == SOCKET_ERROR) { ++ closeSocket(listener); ++ return false; ++ } ++ ++ int addressLength = sizeof(address); ++ if (getsockname(listener, reinterpret_cast(&address), ++ &addressLength) == SOCKET_ERROR) { ++ closeSocket(listener); ++ return false; ++ } ++ ++ sock_t writer = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); ++ if (writer == INVALID_SOCKET_FD) { ++ closeSocket(listener); ++ return false; ++ } ++ if (connect(writer, reinterpret_cast(&address), ++ sizeof(address)) == SOCKET_ERROR) { ++ closeSocket(writer); ++ closeSocket(listener); ++ return false; ++ } ++ ++ sock_t reader = accept(listener, nullptr, nullptr); ++ closeSocket(listener); ++ if (reader == INVALID_SOCKET_FD) { ++ closeSocket(writer); ++ return false; ++ } ++ pair.readSocket = reader; ++ pair.writeSocket = writer; ++#else ++ int sockets[2]; ++ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == -1) { ++ return false; ++ } ++ pair.readSocket = sockets[0]; ++ pair.writeSocket = sockets[1]; ++#endif ++ ++ if (!setNonBlocking(pair.readSocket) || ++ !setNonBlocking(pair.writeSocket)) { ++ closeSocket(pair.readSocket); ++ closeSocket(pair.writeSocket); ++ pair.readSocket = INVALID_SOCKET_FD; ++ pair.writeSocket = INVALID_SOCKET_FD; ++ return false; ++ } ++ ++#ifndef __MINGW32__ ++ util::make_fd_cloexec(pair.readSocket); ++ util::make_fd_cloexec(pair.writeSocket); ++#endif ++ return true; ++} ++ ++void closeWakePair(WakePair& pair) ++{ ++ closeSocket(pair.readSocket); ++ closeSocket(pair.writeSocket); ++ pair.readSocket = INVALID_SOCKET_FD; ++ pair.writeSocket = INVALID_SOCKET_FD; ++} ++ ++void drainSocket(sock_t socket) ++{ ++ if (socket == INVALID_SOCKET_FD) { ++ return; ++ } ++ char buffer[128]; ++ for (;;) { ++ int result = recv(socket, buffer, sizeof(buffer), 0); ++ if (result > 0) { ++ continue; ++ } ++ if (result == 0) { ++ return; ++ } ++ const int error = socketError(); ++#ifdef __MINGW32__ ++ if (error == WSAEINTR) { ++ continue; ++ } ++ if (error == WSAEWOULDBLOCK) { ++ return; ++ } ++#else ++ if (error == EINTR) { ++ continue; ++ } ++ if (error == EAGAIN || error == EWOULDBLOCK) { ++ return; ++ } ++#endif ++ return; ++ } ++} ++ ++void notifySocket(sock_t socket) ++{ ++ if (socket == INVALID_SOCKET_FD) { ++ return; ++ } ++ const char notification = 1; ++ int flags = 0; ++#ifdef MSG_NOSIGNAL ++ flags |= MSG_NOSIGNAL; ++#endif ++ for (;;) { ++ int result = send(socket, ¬ification, 1, flags); ++ if (result >= 0) { ++ return; ++ } ++ const int error = socketError(); ++#ifdef __MINGW32__ ++ if (error == WSAEINTR) { ++ continue; ++ } ++#else ++ if (error == EINTR) { ++ continue; ++ } ++#endif ++ // A full non-blocking socket already has a notification waiting. Any ++ // other failure means the owning resolver has gone away, so there is no ++ // event-loop consumer left to wake. ++ return; ++ } ++} ++ ++using ResolutionResult = NativeAsyncResolver::ResolutionResult; ++ ++struct QueryKey { ++ const NativeAsyncResolver::Backend* backend; ++ std::string hostname; ++ int family; ++ ++ bool operator<(const QueryKey& rhs) const ++ { ++ if (backend != rhs.backend) { ++ return std::less()(backend, ++ rhs.backend); ++ } ++ if (hostname != rhs.hostname) { ++ return hostname < rhs.hostname; ++ } ++ return family < rhs.family; ++ } ++}; ++ ++std::string normalizeHostname(const std::string& hostname) ++{ ++ std::string normalized = hostname; ++ for (char& c : normalized) { ++ c = static_cast(std::tolower(static_cast(c))); ++ } ++ return normalized; ++} ++ ++struct ResolverState { ++ ResolverState() ++ : generation(0), ++ pending(false), ++ ready(false), ++ deadline(std::chrono::steady_clock::time_point::max()) ++ { ++ } ++ ++ std::mutex mutex; ++ uint64_t generation; ++ bool pending; ++ bool ready; ++ std::chrono::steady_clock::time_point deadline; ++ ResolutionResult result; ++}; ++ ++struct Subscriber { ++ std::weak_ptr state; ++ uint64_t generation; ++ std::chrono::steady_clock::time_point deadline; ++}; ++ ++struct Job { ++ Job(const QueryKey& queryKey, ++ const std::shared_ptr& resolverBackend) ++ : key(queryKey), backend(resolverBackend), started(false) ++ { ++ } ++ ++ QueryKey key; ++ std::shared_ptr backend; ++ bool started; ++ std::vector subscribers; ++}; ++ ++struct ResolverPoolState { ++ ResolverPoolState() ++ { ++ if (!createWakePair(wakePair)) { ++ throw std::runtime_error("creating native resolver wake socket failed"); ++ } ++ } ++ ++ ~ResolverPoolState() { closeWakePair(wakePair); } ++ ++ std::mutex mutex; ++ std::condition_variable condition; ++ std::condition_variable stoppedCondition; ++ bool stopping = false; ++ size_t workersAlive = 0; ++ std::deque> pending; ++ std::map> jobs; ++ size_t pendingSubscribers = 0; ++ WakePair wakePair; ++}; ++ ++class SystemResolverBackend : public NativeAsyncResolver::Backend { ++public: ++ ResolutionResult resolve(const std::string& hostname, int family) override ++ { ++ ResolutionResult result; ++ addrinfo* addresses = nullptr; ++ const int status = callGetaddrinfo( ++ &addresses, hostname.c_str(), nullptr, family, SOCK_STREAM, 0, 0); ++ if (status != 0) { ++ result.error = gai_strerror(status); ++ return result; ++ } ++ ++ std::unique_ptr deleter(addresses, ++ freeaddrinfo); ++ for (addrinfo* address = addresses; address; address = address->ai_next) { ++ char numericAddress[NI_MAXHOST]; ++ const int nameStatus = getnameinfo( ++ address->ai_addr, address->ai_addrlen, numericAddress, ++ sizeof(numericAddress), nullptr, 0, NI_NUMERICHOST); ++ if (nameStatus == 0) { ++ result.addresses.emplace_back(numericAddress); ++ } ++ } ++ if (result.addresses.empty()) { ++ result.error = "no address returned or address conversion failed"; ++ } ++ return result; ++ } ++}; ++ ++std::shared_ptr defaultBackend() ++{ ++ static const std::shared_ptr backend = ++ std::make_shared(); ++ return backend; ++} ++ ++/* ++ * Kept as a separate helper so the worker loop has no system resolver logic ++ * and an injected backend follows exactly the same completion path. ++ */ ++ResolutionResult resolveWithBackend( ++ const std::shared_ptr& backend, ++ const QueryKey& key) ++{ ++ try { ++ return backend->resolve(key.hostname, key.family); ++ } ++ catch (...) { ++ ResolutionResult result; ++ result.error = "native resolver backend failure"; ++ return result; ++ } ++} ++ ++class ResolverPool { ++public: ++ static std::shared_ptr acquire() ++ { ++ static std::mutex mutex; ++ // Keep one pool for the lifetime of the process. In particular, do not ++ // create a replacement pool while an old worker is still inside a ++ // non-cancellable getaddrinfo() call; that would defeat the eight-worker ++ // bound during shutdown/restart races. ++ static std::shared_ptr pool; ++ std::lock_guard lock(mutex); ++ if (!pool) { ++ pool = std::shared_ptr(new ResolverPool()); ++ } ++ return pool; ++ } ++ ++ ~ResolverPool() ++ { ++ { ++ std::lock_guard lock(state_->mutex); ++ state_->stopping = true; ++ state_->pending.clear(); ++ state_->jobs.clear(); ++ state_->pendingSubscribers = 0; ++ } ++ state_->condition.notify_all(); ++ ++ bool stopped; ++ { ++ std::unique_lock lock(state_->mutex); ++ stopped = state_->stoppedCondition.wait_for( ++ lock, std::chrono::seconds(1), ++ [this]() { return state_->workersAlive == 0; }); ++ } ++ for (auto& worker : workers_) { ++ if (worker.joinable()) { ++ if (stopped) { ++ worker.join(); ++ } ++ else { ++ // getaddrinfo() has no portable cancellation API. The worker only ++ // captures ResolverPoolState, so detaching it after the bounded ++ // drain cannot leave it dereferencing this object. ++ worker.detach(); ++ } ++ } ++ } ++ } ++ ++ bool submit(const std::shared_ptr& resolver, ++ const std::shared_ptr& backend, ++ uint64_t generation, const QueryKey& key, ++ std::chrono::steady_clock::time_point deadline) ++ { ++ std::lock_guard lock(state_->mutex); ++ if (state_->stopping || state_->pendingSubscribers >= ++ NativeAsyncResolver::MAX_PENDING_SUBSCRIBERS) { ++ return false; ++ } ++ auto itr = state_->jobs.find(key); ++ if (itr != state_->jobs.end()) { ++ itr->second->subscribers.push_back({resolver, generation, deadline}); ++ ++state_->pendingSubscribers; ++ return true; ++ } ++ if (state_->jobs.size() >= NativeAsyncResolver::MAX_PENDING_JOBS) { ++ return false; ++ } ++ auto job = std::make_shared(key, backend); ++ job->subscribers.push_back({resolver, generation, deadline}); ++ ++state_->pendingSubscribers; ++ state_->pending.push_back(job); ++ state_->jobs.emplace(key, job); ++ state_->condition.notify_one(); ++ return true; ++ } ++ ++ void cancel(const std::shared_ptr& resolver, ++ uint64_t generation) ++ { ++ std::lock_guard lock(state_->mutex); ++ for (auto itr = state_->jobs.begin(); itr != state_->jobs.end();) { ++ auto& subscribers = itr->second->subscribers; ++ const size_t subscriberCount = subscribers.size(); ++ subscribers.erase( ++ std::remove_if(subscribers.begin(), subscribers.end(), ++ [&resolver, generation](const Subscriber& subscriber) { ++ auto state = subscriber.state.lock(); ++ return state && state.get() == resolver.get() && ++ subscriber.generation == generation; ++ }), ++ subscribers.end()); ++ state_->pendingSubscribers -= ++ subscriberCount - subscribers.size(); ++ if (subscribers.empty()) { ++ if (!itr->second->started) { ++ const auto abandoned = itr->second; ++ state_->pending.erase( ++ std::remove(state_->pending.begin(), state_->pending.end(), ++ abandoned), ++ state_->pending.end()); ++ } ++ // A started getaddrinfo() cannot be cancelled portably, but a later ++ // request must not coalesce with work whose last owner has gone away. ++ // Detach the old job from the key; complete() will discard its result. ++ itr = state_->jobs.erase(itr); ++ } ++ else { ++ ++itr; ++ } ++ } ++ } ++ ++ sock_t getSocket() const { return state_->wakePair.readSocket; } ++ ++ void notify() const { notifySocket(state_->wakePair.writeSocket); } ++ ++private: ++ ResolverPool() : state_(std::make_shared()) ++ { ++ for (size_t i = 0; i < NativeAsyncResolver::WORKER_COUNT; ++i) { ++ { ++ std::lock_guard lock(state_->mutex); ++ ++state_->workersAlive; ++ } ++ try { ++ auto state = state_; ++ workers_.emplace_back([state]() { workerMain(state); }); ++ } ++ catch (...) { ++ { ++ std::lock_guard lock(state_->mutex); ++ --state_->workersAlive; ++ state_->stopping = true; ++ } ++ state_->condition.notify_all(); ++ for (auto& worker : workers_) { ++ if (worker.joinable()) { ++ worker.join(); ++ } ++ } ++ throw; ++ } ++ } ++ } ++ ++ static void workerMain(const std::shared_ptr& state) ++ { ++ for (;;) { ++ std::shared_ptr job; ++ { ++ std::unique_lock lock(state->mutex); ++ state->condition.wait(lock, [&state]() { ++ return state->stopping || !state->pending.empty(); ++ }); ++ if (state->stopping) { ++ break; ++ } ++ job = state->pending.front(); ++ state->pending.pop_front(); ++ auto itr = state->jobs.find(job->key); ++ if (itr == state->jobs.end() || itr->second.get() != job.get()) { ++ continue; ++ } ++ job->started = true; ++ } ++ ++ ResolutionResult result; ++ std::vector abandonedSubscribers; ++ bool hasLiveSubscriber = false; ++ { ++ std::lock_guard lock(state->mutex); ++ auto itr = state->jobs.find(job->key); ++ if (itr == state->jobs.end() || itr->second.get() != job.get()) { ++ continue; ++ } ++ const auto now = std::chrono::steady_clock::now(); ++ for (const auto& subscriber : job->subscribers) { ++ if (subscriber.state.expired() || now >= subscriber.deadline) { ++ continue; ++ } ++ hasLiveSubscriber = true; ++ break; ++ } ++ if (!hasLiveSubscriber) { ++ // Detach an expired job while holding the same lock used by ++ // submit(). A new lookup must create fresh work instead of joining ++ // a job whose worker has already decided not to call getaddrinfo(). ++ abandonedSubscribers = std::move(job->subscribers); ++ state->pendingSubscribers -= abandonedSubscribers.size(); ++ state->jobs.erase(itr); ++ } ++ } ++ if (hasLiveSubscriber) { ++ result = resolveWithBackend(job->backend, job->key); ++ } ++ else { ++ result.error = "native resolver deadline exceeded"; ++ deliver(state, abandonedSubscribers, std::move(result)); ++ continue; ++ } ++ ++ complete(state, job, std::move(result)); ++ } ++ ++ { ++ std::lock_guard lock(state->mutex); ++ --state->workersAlive; ++ } ++ state->stoppedCondition.notify_all(); ++ } ++ ++ static void deliver(const std::shared_ptr& state, ++ const std::vector& subscribers, ++ ResolutionResult result) ++ { ++ const auto now = std::chrono::steady_clock::now(); ++ for (const auto& subscriber : subscribers) { ++ auto resolver = subscriber.state.lock(); ++ if (!resolver) { ++ continue; ++ } ++ std::lock_guard lock(resolver->mutex); ++ if (resolver->generation != subscriber.generation || ++ !resolver->pending) { ++ continue; ++ } ++ if (now >= subscriber.deadline) { ++ resolver->result.addresses.clear(); ++ resolver->result.error = "native resolver deadline exceeded"; ++ } ++ else { ++ resolver->result = result; ++ } ++ resolver->pending = false; ++ resolver->ready = true; ++ notifySocket(state->wakePair.writeSocket); ++ } ++ } ++ ++ static void complete(const std::shared_ptr& state, ++ const std::shared_ptr& job, ++ ResolutionResult result) ++ { ++ std::vector subscribers; ++ { ++ std::lock_guard lock(state->mutex); ++ auto itr = state->jobs.find(job->key); ++ if (itr == state->jobs.end() || itr->second.get() != job.get()) { ++ return; ++ } ++ subscribers = std::move(job->subscribers); ++ state->pendingSubscribers -= subscribers.size(); ++ state->jobs.erase(itr); ++ } ++ deliver(state, subscribers, std::move(result)); ++ } ++ ++ std::shared_ptr state_; ++ std::vector workers_; ++}; ++ ++} // namespace ++ ++struct NativeAsyncResolver::Impl { ++ explicit Impl(int family, const std::shared_ptr& resolverBackend) ++ : pool(ResolverPool::acquire()), ++ state(std::make_shared()), ++ family(family), ++ backend(resolverBackend ? resolverBackend : defaultBackend()) ++ { ++ } ++ ++ std::shared_ptr pool; ++ std::shared_ptr state; ++ int family; ++ std::shared_ptr backend; ++}; ++ ++NativeAsyncResolver::NativeAsyncResolver( ++ int family, const std::shared_ptr& backend) ++ : impl_(make_unique(family, backend)), ++ status_(STATUS_READY), ++ family_(family) ++{ ++} ++ ++NativeAsyncResolver::~NativeAsyncResolver() { cancel(); } ++ ++void NativeAsyncResolver::resolve( ++ const std::string& name, std::chrono::steady_clock::time_point deadline) ++{ ++ cancel(); ++ hostname_ = name; ++ resolvedAddresses_.clear(); ++ error_.clear(); ++ status_ = STATUS_QUERYING; ++ ++ uint64_t generation; ++ { ++ std::lock_guard lock(impl_->state->mutex); ++ generation = ++impl_->state->generation; ++ impl_->state->pending = true; ++ impl_->state->ready = false; ++ impl_->state->deadline = deadline; ++ impl_->state->result = ResolutionResult(); ++ } ++ ++ QueryKey key{impl_->backend.get(), normalizeHostname(name), family_}; ++ if (!impl_->pool->submit(impl_->state, impl_->backend, generation, key, ++ deadline)) { ++ std::lock_guard lock(impl_->state->mutex); ++ impl_->state->result.error = "native resolver queue is full"; ++ impl_->state->pending = false; ++ impl_->state->ready = true; ++ impl_->pool->notify(); ++ } ++} ++ ++void NativeAsyncResolver::cancel() ++{ ++ if (!impl_) { ++ return; ++ } ++ uint64_t generation; ++ { ++ std::lock_guard lock(impl_->state->mutex); ++ generation = impl_->state->generation++; ++ impl_->state->pending = false; ++ impl_->state->ready = false; ++ impl_->state->deadline = std::chrono::steady_clock::time_point::max(); ++ } ++ impl_->pool->cancel(impl_->state, generation); ++ status_ = STATUS_READY; ++} ++ ++void NativeAsyncResolver::reset() ++{ ++ cancel(); ++ hostname_.clear(); ++ resolvedAddresses_.clear(); ++ error_.clear(); ++ status_ = STATUS_READY; ++} ++ ++sock_t NativeAsyncResolver::getSocket() const ++{ ++ return impl_->pool->getSocket(); ++} ++ ++void NativeAsyncResolver::process(sock_t readfd, sock_t /*writefd*/) ++{ ++ if (readfd != INVALID_SOCKET_FD && readfd != getSocket()) { ++ return; ++ } ++ drainSocket(getSocket()); ++ ++ uint64_t expiredGeneration = 0; ++ bool expired = false; ++ { ++ std::lock_guard lock(impl_->state->mutex); ++ if (impl_->state->pending && ++ std::chrono::steady_clock::now() >= impl_->state->deadline) { ++ expiredGeneration = impl_->state->generation++; ++ impl_->state->pending = false; ++ impl_->state->ready = true; ++ impl_->state->result.addresses.clear(); ++ impl_->state->result.error = "native resolver deadline exceeded"; ++ expired = true; ++ } ++ } ++ if (expired) { ++ impl_->pool->cancel(impl_->state, expiredGeneration); ++ } ++ ++ ResolutionResult result; ++ bool ready = false; ++ { ++ std::lock_guard lock(impl_->state->mutex); ++ if (impl_->state->ready) { ++ result = impl_->state->result; ++ impl_->state->ready = false; ++ ready = true; ++ } ++ } ++ if (!ready) { ++ return; ++ } ++ if (result.addresses.empty()) { ++ error_ = result.error; ++ status_ = STATUS_ERROR; ++ } ++ else { ++ resolvedAddresses_ = std::move(result.addresses); ++ error_.clear(); ++ status_ = STATUS_SUCCESS; ++ } ++} ++ ++} // namespace aria2 +diff --git a/src/NativeAsyncResolver.h b/src/NativeAsyncResolver.h +new file mode 100644 +index 00000000..67ddf872 +--- /dev/null ++++ b/src/NativeAsyncResolver.h +@@ -0,0 +1,140 @@ ++/* */ ++#ifndef D_NATIVE_ASYNC_RESOLVER_H ++#define D_NATIVE_ASYNC_RESOLVER_H ++ ++#include "common.h" ++ ++#include ++#include ++#include ++#include ++ ++#include "a2netcompat.h" ++ ++namespace aria2 { ++ ++/** ++ * Asynchronous resolver backed by the operating system's getaddrinfo(). ++ * ++ * The resolver owns a small socket pair. Worker threads publish completion ++ * by writing to the pair, allowing every EventPoll implementation to receive ++ * completion on its normal event-loop thread. Results are deliberately not ++ * cached: a later attempt must observe the resolver state selected by the ++ * operating system (including a changed VPN/TUN route). ++ */ ++class NativeAsyncResolver { ++public: ++ enum STATUS { ++ STATUS_READY, ++ STATUS_QUERYING, ++ STATUS_SUCCESS, ++ STATUS_ERROR, ++ }; ++ ++ static const size_t WORKER_COUNT = 8; ++ static const size_t MAX_PENDING_JOBS = 1024; ++ static const size_t MAX_PENDING_SUBSCRIBERS = 1024; ++ ++ struct ResolutionResult { ++ std::vector addresses; ++ std::string error; ++ }; ++ ++ /** ++ * Resolver backends run only on the bounded worker pool. The default ++ * backend calls the operating system's getaddrinfo(). The interface is ++ * also used by tests to make stalled resolution and completion fencing ++ * deterministic without depending on the host's DNS configuration. ++ */ ++ class Backend { ++ public: ++ virtual ~Backend() = default; ++ ++ virtual ResolutionResult resolve(const std::string& hostname, ++ int family) = 0; ++ }; ++ ++ explicit NativeAsyncResolver( ++ int family, const std::shared_ptr& backend = nullptr); ++ ~NativeAsyncResolver(); ++ ++ NativeAsyncResolver(const NativeAsyncResolver&) = delete; ++ NativeAsyncResolver& operator=(const NativeAsyncResolver&) = delete; ++ ++ /** Start a query with a deadline on the shared resolver pool. */ ++ void resolve(const std::string& name, ++ std::chrono::steady_clock::time_point deadline); ++ ++ /** Invalidate the current query without waiting for getaddrinfo(). */ ++ void cancel(); ++ ++ /** Reset the resolver to STATUS_READY and discard pending notifications. */ ++ void reset(); ++ ++ STATUS getStatus() const { return status_; } ++ ++ const std::vector& getResolvedAddresses() const ++ { ++ return resolvedAddresses_; ++ } ++ ++ const std::string& getError() const { return error_; } ++ ++ const std::string& getHostname() const { return hostname_; } ++ ++ /** Returns the read side used to wake the EventPoll implementation. */ ++ sock_t getSocket() const; ++ ++ /** Process a readable completion notification on the event-loop thread. */ ++ void process(sock_t readfd, sock_t writefd); ++ ++ static size_t workerCount() { return WORKER_COUNT; } ++ static size_t maxPendingJobs() { return MAX_PENDING_JOBS; } ++ static size_t maxPendingSubscribers() { return MAX_PENDING_SUBSCRIBERS; } ++ ++private: ++ struct Impl; ++ std::unique_ptr impl_; ++ ++ STATUS status_; ++ int family_; ++ std::vector resolvedAddresses_; ++ std::string error_; ++ std::string hostname_; ++}; ++ ++} // namespace aria2 ++ ++#endif // D_NATIVE_ASYNC_RESOLVER_H +diff --git a/src/NetworkTargetPolicy.cc b/src/NetworkTargetPolicy.cc +new file mode 100644 +index 00000000..6f060b3e +--- /dev/null ++++ b/src/NetworkTargetPolicy.cc +@@ -0,0 +1,369 @@ ++/* */ ++#include "NetworkTargetPolicy.h" ++ ++#include ++#include ++#include ++#include ++#include ++ ++#include "FeatureConfig.h" ++#include "Option.h" ++#include "SocketCore.h" ++#include "prefs.h" ++#include "uri.h" ++ ++namespace aria2 { ++namespace network { ++ ++namespace { ++ ++void setReason(std::string* reason, const char* value) ++{ ++ if (reason) { ++ *reason = value; ++ } ++} ++ ++std::string normalizeHost(const std::string& host) ++{ ++ std::string normalized = host; ++ while (!normalized.empty() && normalized.back() == '.') { ++ normalized.pop_back(); ++ } ++ for (char& c : normalized) { ++ c = static_cast(std::tolower(static_cast(c))); ++ } ++ return normalized; ++} ++ ++bool isLocalName(const std::string& host) ++{ ++ if (host == "localhost" || host == "local" || host == "broadcasthost" || ++ host == "localhost.localdomain" || host == "localhost6" || ++ host == "localhost6.localdomain6" || host == "ip6-localhost" || ++ host == "ip6-loopback" || host == "ip6-allnodes" || ++ host == "ip6-allrouters") { ++ return true; ++ } ++ return host.size() >= 10 && ++ host.compare(host.size() - 10, 10, ".localhost") == 0; ++} ++ ++bool isLocalDomain(const std::string& host) ++{ ++ return host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0; ++} ++ ++bool isIPv4Special(const in_addr& address) ++{ ++ const uint32_t value = ntohl(address.s_addr); ++ const auto inCidr = [value](uint32_t network, uint32_t mask) { ++ return (value & mask) == network; ++ }; ++ ++ // Match Firelink's literal local/private policy. Documentation and other ++ // globally routed special-purpose ranges are not local targets and remain ++ // usable; hostname answers are never inspected here. ++ return inCidr(0x00000000u, 0xff000000u) || ++ inCidr(0x0a000000u, 0xff000000u) || ++ inCidr(0x64400000u, 0xffc00000u) || ++ inCidr(0x7f000000u, 0xff000000u) || ++ inCidr(0xa9fe0000u, 0xffff0000u) || ++ inCidr(0xac100000u, 0xfff00000u) || ++ inCidr(0xc0a80000u, 0xffff0000u) || ++ inCidr(0xe0000000u, 0xf0000000u) || ++ value == 0xffffffffu; ++} ++ ++bool allBytesZero(const unsigned char* address) ++{ ++ for (size_t i = 0; i < 16; ++i) { ++ if (address[i] != 0) { ++ return false; ++ } ++ } ++ return true; ++} ++ ++bool isIPv6Special(const in6_addr& address) ++{ ++ const unsigned char* bytes = address.s6_addr; ++ if (allBytesZero(bytes) || ++ (bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0 && ++ bytes[4] == 0 && bytes[5] == 0 && bytes[6] == 0 && bytes[7] == 0 && ++ bytes[8] == 0 && bytes[9] == 0 && bytes[10] == 0 && bytes[11] == 0 && ++ bytes[12] == 0 && bytes[13] == 0 && bytes[14] == 0 && bytes[15] == 1) || ++ (bytes[0] & 0xfeu) == 0xfcu || ++ (bytes[0] == 0xfeu && (bytes[1] & 0xc0u) == 0x80u) || ++ (bytes[0] == 0xfeu && (bytes[1] & 0xc0u) == 0xc0u) || ++ bytes[0] == 0xffu) { ++ return true; ++ } ++ ++ const bool zero96 = std::all_of(bytes, bytes + 12, ++ [](unsigned char byte) { return byte == 0; }); ++ const bool mapped = ++ std::all_of(bytes, bytes + 10, ++ [](unsigned char byte) { return byte == 0; }) && ++ bytes[10] == 0xffu && bytes[11] == 0xffu; ++ if (zero96 || mapped) { ++ in_addr embedded; ++ std::memcpy(&embedded, bytes + 12, sizeof(embedded)); ++ return isIPv4Special(embedded); ++ } ++ return false; ++} ++ ++bool validCredential(const std::string& credential) ++{ ++ for (unsigned char c : credential) { ++ if (c < 0x20u || c == 0x7fu) { ++ return false; ++ } ++ } ++ return true; ++} ++ ++bool parseLegacyIPv4Number(const std::string& component, uint32_t* value) ++{ ++ if (component.empty()) { ++ return false; ++ } ++ size_t offset = 0; ++ unsigned int base = 10; ++ if (component.size() > 2 && component[0] == '0' && ++ (component[1] == 'x' || component[1] == 'X')) { ++ base = 16; ++ offset = 2; ++ } ++ else if (component.size() > 1 && component[0] == '0') { ++ base = 8; ++ offset = 1; ++ } ++ if (offset == component.size()) { ++ return false; ++ } ++ ++ uint64_t parsed = 0; ++ for (; offset < component.size(); ++offset) { ++ const unsigned char character = component[offset]; ++ unsigned int digit; ++ if (character >= '0' && character <= '9') { ++ digit = character - '0'; ++ } ++ else if (character >= 'a' && character <= 'f') { ++ digit = character - 'a' + 10; ++ } ++ else if (character >= 'A' && character <= 'F') { ++ digit = character - 'A' + 10; ++ } ++ else { ++ return false; ++ } ++ if (digit >= base || parsed > ++ (std::numeric_limits::max() - digit) / ++ base) { ++ return false; ++ } ++ parsed = parsed * base + digit; ++ } ++ *value = static_cast(parsed); ++ return true; ++} ++ ++bool parseLegacyIPv4(const std::string& host, in_addr* address) ++{ ++ std::vector parts; ++ size_t begin = 0; ++ for (;;) { ++ const size_t end = host.find('.', begin); ++ uint32_t value; ++ if (!parseLegacyIPv4Number( ++ host.substr(begin, end == std::string::npos ? end : end - begin), ++ &value)) { ++ return false; ++ } ++ parts.push_back(value); ++ if (end == std::string::npos) { ++ break; ++ } ++ if (parts.size() == 4) { ++ return false; ++ } ++ begin = end + 1; ++ } ++ ++ uint32_t value; ++ switch (parts.size()) { ++ case 1: ++ value = parts[0]; ++ break; ++ case 2: ++ if (parts[0] > 0xffu || parts[1] > 0xffffffu) { ++ return false; ++ } ++ value = (parts[0] << 24) | parts[1]; ++ break; ++ case 3: ++ if (parts[0] > 0xffu || parts[1] > 0xffu || parts[2] > 0xffffu) { ++ return false; ++ } ++ value = (parts[0] << 24) | (parts[1] << 16) | parts[2]; ++ break; ++ case 4: ++ if (std::any_of(parts.begin(), parts.end(), ++ [](uint32_t part) { return part > 0xffu; })) { ++ return false; ++ } ++ value = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | ++ parts[3]; ++ break; ++ default: ++ return false; ++ } ++ address->s_addr = htonl(value); ++ return true; ++} ++ ++} // namespace ++ ++bool isFirelinkPolicy(const std::string& value) ++{ ++ return value == "firelink-v1"; ++} ++ ++bool validateHost(const std::string& host, std::string* reason) ++{ ++ if (host.empty()) { ++ setReason(reason, "destination host is empty"); ++ return false; ++ } ++ for (unsigned char c : host) { ++ if (c < 0x21u || c == 0x7fu) { ++ setReason(reason, "destination host contains control characters"); ++ return false; ++ } ++ } ++ ++ const std::string normalized = normalizeHost(host); ++ if (normalized.empty()) { ++ setReason(reason, "destination host is empty"); ++ return false; ++ } ++ if (isLocalName(normalized) || isLocalDomain(normalized)) { ++ setReason(reason, "localhost or local domain is not allowed"); ++ return false; ++ } ++ ++ // A scoped IPv6 literal is interface-local and is not a DNS hostname. ++ // Reject it before numeric parsing so platform-specific getaddrinfo ++ // extensions cannot turn it into an unclassified literal. ++ if (normalized.find('%') != std::string::npos) { ++ setReason(reason, "scoped IP literal is not allowed"); ++ return false; ++ } ++ ++ unsigned char binaryAddress[16] = {}; ++ std::string numericHost = normalized; ++ bool bracketed = false; ++ if (numericHost.front() == '[' || numericHost.back() == ']') { ++ if (numericHost.size() < 2 || numericHost.front() != '[' || ++ numericHost.back() != ']') { ++ setReason(reason, "malformed bracketed destination host"); ++ return false; ++ } ++ bracketed = true; ++ numericHost = numericHost.substr(1, numericHost.size() - 2); ++ if (numericHost.empty()) { ++ setReason(reason, "destination host is empty"); ++ return false; ++ } ++ } ++ ++ // inet_pton-style helpers are not required to preserve inet_aton base ++ // rules. Classify legacy numeric spellings first so octal and hexadecimal ++ // loopback forms cannot be reinterpreted as unrelated decimal addresses. ++ in_addr legacyAddress; ++ if (parseLegacyIPv4(numericHost, &legacyAddress)) { ++ if (isIPv4Special(legacyAddress)) { ++ setReason(reason, "local or private IPv4 address is not allowed"); ++ return false; ++ } ++ return true; ++ } ++ ++ // Parse the normalized form so trailing-dot spellings of numeric literals ++ // cannot bypass the literal-address policy. ++ const size_t length = net::getBinAddr(binaryAddress, numericHost); ++ if (length == sizeof(in_addr)) { ++ in_addr address; ++ std::memcpy(&address, binaryAddress, sizeof(address)); ++ if (isIPv4Special(address)) { ++ setReason(reason, "local or private IPv4 address is not allowed"); ++ return false; ++ } ++ return true; ++ } ++ if (length == sizeof(in6_addr)) { ++ in6_addr address; ++ std::memcpy(&address, binaryAddress, sizeof(address)); ++ if (isIPv6Special(address)) { ++ setReason(reason, "local or private IPv6 address is not allowed"); ++ return false; ++ } ++ return true; ++ } ++ ++ if (bracketed) { ++ setReason(reason, "bracketed destination host is not an IP literal"); ++ return false; ++ } ++ ++ // A hostname is intentionally not resolved here. This branch also keeps ++ // private-looking DNS answers usable through a TUN or proxy route. ++ return true; ++} ++ ++bool validateUri(const std::string& value, std::string* reason) ++{ ++ uri::UriStruct parsed; ++ if (!uri::parse(parsed, value)) { ++ setReason(reason, "invalid download URI"); ++ return false; ++ } ++ if (parsed.protocol != "http" && parsed.protocol != "https" && ++ parsed.protocol != "ftp" && parsed.protocol != "sftp") { ++ setReason(reason, "unsupported download URI scheme"); ++ return false; ++ } ++ if (parsed.host.empty()) { ++ setReason(reason, "download URI has no destination host"); ++ return false; ++ } ++ if (!validCredential(parsed.username) || !validCredential(parsed.password)) { ++ setReason(reason, "download URI credentials contain control characters"); ++ return false; ++ } ++ return validateHost(parsed.host, reason); ++} ++ ++bool isFirelinkPolicyEnabled(const Option* option) ++{ ++ return option && option->defined(PREF_NETWORK_TARGET_POLICY) && ++ isFirelinkPolicy(option->get(PREF_NETWORK_TARGET_POLICY)); ++} ++ ++} // namespace network ++} // namespace aria2 +diff --git a/src/NetworkTargetPolicy.h b/src/NetworkTargetPolicy.h +new file mode 100644 +index 00000000..bbabe9e6 +--- /dev/null ++++ b/src/NetworkTargetPolicy.h +@@ -0,0 +1,43 @@ ++/* */ ++#ifndef D_NETWORK_TARGET_POLICY_H ++#define D_NETWORK_TARGET_POLICY_H ++ ++#include ++ ++namespace aria2 { ++ ++class Option; ++ ++namespace network { ++ ++/** Returns true when |value| selects the versioned Firelink policy. */ ++bool isFirelinkPolicy(const std::string& value); ++ ++/** Returns true only when the supplied runtime option explicitly enables it. */ ++bool isFirelinkPolicyEnabled(const Option* option); ++ ++/** ++ * Validate a network destination without resolving hostnames. Hostnames are ++ * deliberately accepted based on their spelling; their answers belong to the ++ * selected OS/proxy/TUN route and are not inspected here. ++ */ ++bool validateHost(const std::string& host, std::string* reason = nullptr); ++ ++/** Validate a supported download URI and its destination host. */ ++bool validateUri(const std::string& value, std::string* reason = nullptr); ++ ++} // namespace network ++} // namespace aria2 ++ ++#endif // D_NETWORK_TARGET_POLICY_H +diff --git a/src/OptionHandlerFactory.cc b/src/OptionHandlerFactory.cc +index 2cd2c43e..ef146ff6 100644 +--- a/src/OptionHandlerFactory.cc ++++ b/src/OptionHandlerFactory.cc +@@ -99,0 +100,25 @@ std::vector OptionHandlerFactory::createOptionHandlers() ++ handlers.push_back(op); ++ } ++ { ++ OptionHandler* op(new ParameterOptionHandler( ++ PREF_DNS_RESOLVER, TEXT_DNS_RESOLVER, V_NATIVE_ASYNC, ++# ifdef HAVE_LIBCARES ++ {V_NATIVE_ASYNC, V_CARES} ++# else ++ {V_NATIVE_ASYNC} ++# endif ++ )); ++ op->addTag(TAG_ADVANCED); ++ op->setInitialOption(true); ++ op->setChangeGlobalOption(true); ++ op->setChangeOptionForReserved(true); ++ handlers.push_back(op); ++ } ++ { ++ OptionHandler* op(new ParameterOptionHandler( ++ PREF_NETWORK_TARGET_POLICY, TEXT_NETWORK_TARGET_POLICY, V_FIRELINK_V1, ++ {V_NONE, V_FIRELINK_V1})); ++ op->addTag(TAG_ADVANCED); ++ op->setInitialOption(true); ++ op->setChangeGlobalOption(true); ++ op->setChangeOptionForReserved(true); +diff --git a/src/PeerInitiateConnectionCommand.cc b/src/PeerInitiateConnectionCommand.cc +index af8206a4..f0f9d19e 100644 +--- a/src/PeerInitiateConnectionCommand.cc ++++ b/src/PeerInitiateConnectionCommand.cc +@@ -39,0 +40 @@ ++#include "DlRetryEx.h" +@@ -52,0 +54 @@ ++#include "NetworkTargetPolicy.h" +@@ -76,0 +79,7 @@ bool PeerInitiateConnectionCommand::executeInternal() ++ if (network::isFirelinkPolicyEnabled(getDownloadEngine()->getOption())) { ++ std::string reason; ++ if (!network::validateHost(getPeer()->getIPAddress(), &reason)) { ++ throw DL_RETRY_EX(fmt("BitTorrent peer target rejected by policy: %s", ++ reason.c_str())); ++ } ++ } +diff --git a/src/Platform.cc b/src/Platform.cc +index 9f3ddb25..76943701 100644 +--- a/src/Platform.cc ++++ b/src/Platform.cc +@@ -56 +56 @@ +-#ifdef ENABLE_ASYNC_DNS ++#ifdef HAVE_LIBCARES +@@ -58 +58 @@ +-#endif // ENABLE_ASYNC_DNS ++#endif // HAVE_LIBCARES +diff --git a/src/Request.cc b/src/Request.cc +index 9fea2224..16e4cec8 100644 +--- a/src/Request.cc ++++ b/src/Request.cc +@@ -36,0 +37 @@ ++#include +@@ -64,0 +66 @@ Request::Request() ++ routeResolvedPort_(0), +@@ -86,0 +89 @@ bool Request::setUri(const std::string& uri) ++ clearRouteResolvedAddresses(); +@@ -93,0 +97 @@ bool Request::resetUri() ++ clearRouteResolvedAddresses(); +@@ -105,0 +110 @@ bool Request::redirectUri(const std::string& uri) ++ clearRouteResolvedAddresses(); +@@ -201,0 +207,57 @@ void Request::setConnectedAddrInfo(const std::string& hostname, ++void Request::setRouteResolvedAddresses( ++ const std::string& hostname, uint16_t port, ++ const std::vector& addresses) ++{ ++ routeResolvedHostname_ = hostname; ++ routeResolvedPort_ = port; ++ routeResolvedAddresses_.clear(); ++ for (const auto& address : addresses) { ++ if (std::find(routeResolvedAddresses_.begin(), ++ routeResolvedAddresses_.end(), ++ address) == routeResolvedAddresses_.end()) { ++ routeResolvedAddresses_.push_back(address); ++ } ++ } ++} ++ ++bool Request::getRouteResolvedAddresses( ++ const std::string& hostname, uint16_t port, ++ std::vector& addresses) const ++{ ++ if (routeResolvedHostname_ != hostname || routeResolvedPort_ != port || ++ routeResolvedAddresses_.empty()) { ++ return false; ++ } ++ addresses.insert(addresses.end(), routeResolvedAddresses_.begin(), ++ routeResolvedAddresses_.end()); ++ return true; ++} ++ ++bool Request::markRouteResolvedAddressBad(const std::string& hostname, ++ const std::string& addr, ++ uint16_t port) ++{ ++ if (routeResolvedHostname_ != hostname || routeResolvedPort_ != port) { ++ return false; ++ } ++ ++ auto i = std::find(routeResolvedAddresses_.begin(), ++ routeResolvedAddresses_.end(), addr); ++ if (i == routeResolvedAddresses_.end()) { ++ return false; ++ } ++ routeResolvedAddresses_.erase(i); ++ if (routeResolvedAddresses_.empty()) { ++ clearRouteResolvedAddresses(); ++ return false; ++ } ++ return true; ++} ++ ++void Request::clearRouteResolvedAddresses() ++{ ++ routeResolvedHostname_.clear(); ++ routeResolvedPort_ = 0; ++ routeResolvedAddresses_.clear(); ++} ++ +diff --git a/src/Request.h b/src/Request.h +index cd28efb4..43388d12 100644 +--- a/src/Request.h ++++ b/src/Request.h +@@ -40,0 +41 @@ ++#include +@@ -61,0 +63,8 @@ private: ++ // Addresses resolved for the current route-aware connection attempt. ++ // Unlike DNSCache, this state belongs to one Request and is discarded ++ // when the attempt succeeds, is retried, or changes URI. This preserves ++ // address failover without pinning a hostname to an old TUN/proxy route. ++ std::string routeResolvedHostname_; ++ uint16_t routeResolvedPort_; ++ std::vector routeResolvedAddresses_; ++ +@@ -161,0 +171,14 @@ public: ++ void setRouteResolvedAddresses( ++ const std::string& hostname, uint16_t port, ++ const std::vector& addresses); ++ ++ bool getRouteResolvedAddresses(const std::string& hostname, uint16_t port, ++ std::vector& addresses) const; ++ ++ // Removes a failed address from the current route-aware attempt. Returns ++ // true if another address remains available for the same endpoint. ++ bool markRouteResolvedAddressBad(const std::string& hostname, ++ const std::string& addr, uint16_t port); ++ ++ void clearRouteResolvedAddresses(); ++ +diff --git a/src/RequestGroup.cc b/src/RequestGroup.cc +index fd6801fc..44826793 100644 +--- a/src/RequestGroup.cc ++++ b/src/RequestGroup.cc +@@ -383,0 +384 @@ void RequestGroup::createInitialCommand( ++ command->setMessageDispatcher(data.messageDispatcher.get()); +@@ -394,0 +396 @@ void RequestGroup::createInitialCommand( ++ command->setMessageDispatcher(data.messageDispatcher.get()); +diff --git a/src/RpcMethodImpl.cc b/src/RpcMethodImpl.cc +index c2249202..e9983c9c 100644 +--- a/src/RpcMethodImpl.cc ++++ b/src/RpcMethodImpl.cc +@@ -80,0 +81,4 @@ ++#include "FileAllocationMan.h" ++#include "FileAllocationEntry.h" ++#include "FirelinkCapabilities.h" ++#include "NetworkTargetPolicy.h" +@@ -129,0 +134,11 @@ const char KEY_ENABLED_FEATURES[] = "enabledFeatures"; ++const char KEY_FIRELINK_REVISION[] = "firelinkRevision"; ++const char KEY_FIRELINK_DNS_RESOLVER[] = "firelinkDnsResolver"; ++const char KEY_FIRELINK_DNS_RESOLVERS[] = "firelinkDnsResolvers"; ++const char KEY_FIRELINK_NETWORK_TARGET_POLICY[] = ++ "firelinkNetworkTargetPolicy"; ++const char KEY_FIRELINK_NETWORK_TARGET_POLICIES[] = ++ "firelinkNetworkTargetPolicies"; ++const char KEY_FIRELINK_NETWORK_TARGET_POLICY_DIGEST[] = ++ "firelinkNetworkTargetPolicyDigest"; ++const char KEY_FIRELINK_NETWORK_TARGET_POLICY_ENFORCED[] = ++ "firelinkNetworkTargetPolicyEnforced"; +@@ -791,0 +807,9 @@ void gatherProgress(Dict* entryDict, const std::shared_ptr& group, ++ if (requested_key(keys, "fileAllocationPending")) { ++ const auto matches = [&group](const FileAllocationEntry& entry) { ++ return entry.getRequestGroup() == group.get(); ++ }; ++ const auto& manager = e->getFileAllocationMan(); ++ const bool pending = manager && ++ (manager->isPicked(matches) || manager->isQueued(matches)); ++ entryDict->put("fileAllocationPending", pending ? Bool::gTrue() : Bool::gFalse()); ++ } +@@ -823,0 +848,3 @@ void gatherStoppedDownload(Dict* entryDict, ++ if (requested_key(keys, "fileAllocationPending")) { ++ entryDict->put("fileAllocationPending", Bool::gFalse()); ++ } +@@ -1169,0 +1197,57 @@ std::unique_ptr GetVersionRpcMethod::process(const RpcRequest& req, ++ ++ const auto& option = e->getOption(); ++ std::string selectedResolver = "disabled"; ++ if (option && option->defined(PREF_ASYNC_DNS) && ++ option->getAsBool(PREF_ASYNC_DNS)) { ++ const std::string configuredResolver = ++ option->defined(PREF_DNS_RESOLVER) ++ ? option->get(PREF_DNS_RESOLVER) ++ : firelink::DNS_RESOLVER; ++ if (configuredResolver == V_CARES) { ++#ifdef HAVE_LIBCARES ++ selectedResolver = V_CARES; ++#else ++ selectedResolver = "unavailable"; ++#endif // HAVE_LIBCARES ++ } ++ else if (configuredResolver == V_NATIVE_ASYNC) { ++ selectedResolver = V_NATIVE_ASYNC; ++ } ++ else { ++ selectedResolver = "unsupported"; ++ } ++ } ++ auto resolverCapabilities = List::g(); ++ resolverCapabilities->append(V_NATIVE_ASYNC); ++#ifdef HAVE_LIBCARES ++ resolverCapabilities->append(V_CARES); ++#endif // HAVE_LIBCARES ++ ++ std::string selectedPolicy = V_NONE; ++ if (option && option->defined(PREF_NETWORK_TARGET_POLICY)) { ++ const std::string& configuredPolicy = ++ option->get(PREF_NETWORK_TARGET_POLICY); ++ if (configuredPolicy == V_NONE || ++ network::isFirelinkPolicy(configuredPolicy)) { ++ selectedPolicy = configuredPolicy; ++ } ++ else { ++ selectedPolicy = "unsupported"; ++ } ++ } ++ auto policyCapabilities = List::g(); ++ policyCapabilities->append(V_NONE); ++ policyCapabilities->append(V_FIRELINK_V1); ++ ++ result->put("firelinkAllocationTelemetry", Bool::gTrue()); ++ result->put(KEY_FIRELINK_REVISION, firelink::REVISION); ++ result->put(KEY_FIRELINK_DNS_RESOLVER, selectedResolver); ++ result->put(KEY_FIRELINK_DNS_RESOLVERS, std::move(resolverCapabilities)); ++ result->put(KEY_FIRELINK_NETWORK_TARGET_POLICY, selectedPolicy); ++ result->put(KEY_FIRELINK_NETWORK_TARGET_POLICIES, ++ std::move(policyCapabilities)); ++ result->put(KEY_FIRELINK_NETWORK_TARGET_POLICY_DIGEST, ++ firelink::NETWORK_TARGET_POLICY_DIGEST); ++ result->put(KEY_FIRELINK_NETWORK_TARGET_POLICY_ENFORCED, ++ network::isFirelinkPolicy(selectedPolicy) ? Bool::gTrue() ++ : Bool::gFalse()); +diff --git a/src/UDPTrackerClient.cc b/src/UDPTrackerClient.cc +index 2adae9e1..25b5d522 100644 +--- a/src/UDPTrackerClient.cc ++++ b/src/UDPTrackerClient.cc +@@ -116 +116,3 @@ struct CollectAddrPortMatch { +- if (req->remoteAddr == remoteAddr && req->remotePort == remotePort) { ++ if ((req->remoteAddr == remoteAddr && req->remotePort == remotePort) || ++ (!altRemoteAddr.empty() && req->remoteAddr == altRemoteAddr && ++ req->remotePort == altRemotePort)) { +@@ -126,0 +129,2 @@ struct CollectAddrPortMatch { ++ std::string altRemoteAddr; ++ uint16_t altRemotePort; +@@ -128,2 +132,8 @@ struct CollectAddrPortMatch { +- std::string remoteAddr, uint16_t remotePort) +- : dest(dest), remoteAddr(std::move(remoteAddr)), remotePort(remotePort) ++ std::string remoteAddr, uint16_t remotePort, ++ std::string altRemoteAddr = "", ++ uint16_t altRemotePort = 0) ++ : dest(dest), ++ remoteAddr(std::move(remoteAddr)), ++ remotePort(remotePort), ++ altRemoteAddr(std::move(altRemoteAddr)), ++ altRemotePort(altRemotePort) +@@ -161,0 +172,5 @@ int UDPTrackerClient::receiveReply(std::shared_ptr& recvReq, ++ const std::string originalAddr = req->remoteAddr; ++ const uint16_t originalPort = req->remotePort; ++ if (originalAddr != remoteAddr || originalPort != remotePort) { ++ connectionIdCache_[std::make_pair(originalAddr, originalPort)] = c; ++ } +@@ -166,2 +181,4 @@ int UDPTrackerClient::receiveReply(std::shared_ptr& recvReq, +- std::remove_if(connectRequests_.begin(), connectRequests_.end(), +- CollectAddrPortMatch(reqs, remoteAddr, remotePort)), ++ std::remove_if( ++ connectRequests_.begin(), connectRequests_.end(), ++ CollectAddrPortMatch(reqs, remoteAddr, remotePort, originalAddr, ++ originalPort)), +@@ -384 +401 @@ struct TimeoutCheck { +- if (t >= 5_s) { ++ if (t >= 3_s) { +@@ -412 +429 @@ struct TimeoutCheck { +- if (t >= 10_s) { ++ if (t >= 5_s) { +@@ -478 +495 @@ UDPTrackerClient::findInflightRequest(const std::string& remoteAddr, +- break; ++ return res; +@@ -480,0 +498,35 @@ UDPTrackerClient::findInflightRequest(const std::string& remoteAddr, ++ ++ // Fallback for TUN / Fake-IP / NAT translated replies: match by unique transactionId ++ // Prefer matching port + transactionId first ++ for (auto i = inflightRequests_.begin(), eoi = inflightRequests_.end(); ++ i != eoi; ++i) { ++ if ((*i)->remotePort == remotePort && ++ (*i)->transactionId == transactionId) { ++ A2_LOG_INFO(fmt("UDPT matched inflight request by transactionId=%08x and port=%u " ++ "across address translation (expected %s:%u, got %s:%u)", ++ transactionId, remotePort, (*i)->remoteAddr.c_str(), ++ (*i)->remotePort, remoteAddr.c_str(), remotePort)); ++ res = *i; ++ if (remove) { ++ inflightRequests_.erase(i); ++ } ++ return res; ++ } ++ } ++ ++ // Fallback to matching transactionId alone if port was also rewritten ++ for (auto i = inflightRequests_.begin(), eoi = inflightRequests_.end(); ++ i != eoi; ++i) { ++ if ((*i)->transactionId == transactionId) { ++ A2_LOG_INFO(fmt("UDPT matched inflight request by transactionId=%08x " ++ "across address translation (expected %s:%u, got %s:%u)", ++ transactionId, (*i)->remoteAddr.c_str(), ++ (*i)->remotePort, remoteAddr.c_str(), remotePort)); ++ res = *i; ++ if (remove) { ++ inflightRequests_.erase(i); ++ } ++ return res; ++ } ++ } ++ +diff --git a/src/download_helper.cc b/src/download_helper.cc +index 6118aa14..58d99e8d 100644 +--- a/src/download_helper.cc ++++ b/src/download_helper.cc +@@ -49,0 +50 @@ ++#include "error_code.h" +@@ -62,0 +64 @@ ++#include "NetworkTargetPolicy.h" +@@ -65,0 +68 @@ ++#include "uri.h" +@@ -461,0 +465,8 @@ namespace { ++bool hasNetworkSchemePrefix(const std::string& value) ++{ ++ return util::istartsWith(value, "http:") || ++ util::istartsWith(value, "https:") || ++ util::istartsWith(value, "ftp:") || ++ util::istartsWith(value, "sftp:"); ++} ++ +@@ -485,0 +497,30 @@ void createRequestGroupForUri( ++ ++ if (network::isFirelinkPolicyEnabled(option.get())) { ++ std::vector accepted; ++ accepted.reserve(nargs.size()); ++ for (const auto& value : nargs) { ++ uri::UriStruct parsed; ++ const bool parsedUri = uri::parse(parsed, value); ++ // Magnet links and local torrent/metalink paths are handled by their ++ // dedicated parsers. Parsed network URIs are checked before entering ++ // a RequestGroup and checked again at connection time after redirects. ++ if (!parsedUri && !hasNetworkSchemePrefix(value)) { ++ accepted.push_back(value); ++ continue; ++ } ++ std::string reason; ++ if (parsedUri && network::validateUri(value, &reason)) { ++ accepted.push_back(value); ++ continue; ++ } ++ if (reason.empty()) { ++ reason = "invalid download URI"; ++ } ++ if (throwOnError) { ++ throw DL_ABORT_EX2(fmt("Network target rejected: %s", reason.c_str()), ++ error_code::NAME_RESOLVE_ERROR); ++ } ++ A2_LOG_ERROR(fmt("Network target rejected: %s", reason.c_str())); ++ } ++ nargs.swap(accepted); ++ } +diff --git a/src/prefs.cc b/src/prefs.cc +index 2591b9f0..f113a2eb 100644 +--- a/src/prefs.cc ++++ b/src/prefs.cc +@@ -152,0 +153,3 @@ const std::string V_FTP("ftp"); ++const std::string V_NATIVE_ASYNC("native-async"); ++const std::string V_CARES("c-ares"); ++const std::string V_FIRELINK_V1("firelink-v1"); +@@ -246,0 +250,4 @@ PrefPtr PREF_ASYNC_DNS = makePref("async-dns"); ++// value: native-async | c-ares ++PrefPtr PREF_DNS_RESOLVER = makePref("dns-resolver"); ++// value: none | firelink-v1 ++PrefPtr PREF_NETWORK_TARGET_POLICY = makePref("network-target-policy"); +diff --git a/src/prefs.h b/src/prefs.h +index 338fd6e6..e2d29193 100644 +--- a/src/prefs.h ++++ b/src/prefs.h +@@ -109,0 +110,3 @@ extern const std::string V_FTP; ++extern const std::string V_NATIVE_ASYNC; ++extern const std::string V_CARES; ++extern const std::string V_FIRELINK_V1; +@@ -199,0 +203,4 @@ extern PrefPtr PREF_ASYNC_DNS; ++// value: native-async | c-ares ++extern PrefPtr PREF_DNS_RESOLVER; ++// value: none | firelink-v1 ++extern PrefPtr PREF_NETWORK_TARGET_POLICY; +diff --git a/src/usage_text.h b/src/usage_text.h +index 48684565..f8837ffb 100644 +--- a/src/usage_text.h ++++ b/src/usage_text.h +@@ -453,0 +454,6 @@ ++#define TEXT_DNS_RESOLVER \ ++ _(" --dns-resolver=MODE Select asynchronous DNS resolver (native-async\n" \ ++ " or c-ares).") ++#define TEXT_NETWORK_TARGET_POLICY \ ++ _(" --network-target-policy=POLICY Select network target policy (none or\n" \ ++ " firelink-v1).") +diff --git a/test/AllTest.cc b/test/AllTest.cc +index f869e55c..517de998 100644 +--- a/test/AllTest.cc ++++ b/test/AllTest.cc +@@ -50 +50,5 @@ int main(int argc, char* argv[]) +- bool successfull = runner.run(); ++ std::string testPath = ""; ++ if (argc > 1) { ++ testPath = argv[1]; ++ } ++ bool successfull = runner.run(testPath); +diff --git a/test/AsyncNameResolverTest.cc b/test/AsyncNameResolverTest.cc +index 0c793bc8..c7a9cc37 100644 +--- a/test/AsyncNameResolverTest.cc ++++ b/test/AsyncNameResolverTest.cc +@@ -2,0 +3,2 @@ ++#include ++#include +@@ -3,0 +6,4 @@ ++#include ++#include ++#include ++#include +@@ -7,0 +14,4 @@ ++#include "Option.h" ++#include "OptionHandler.h" ++#include "OptionParser.h" ++#include "prefs.h" +@@ -14,0 +25,5 @@ class AsyncNameResolverTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST(testNativeResolver); ++ CPPUNIT_TEST(testNativeResolverCancellation); ++ CPPUNIT_TEST(testNativeResolverWorkerBoundAndDeadline); ++ CPPUNIT_TEST(testNativeResolverCoalescing); ++ CPPUNIT_TEST(testResolverOption); +@@ -22,0 +38,5 @@ public: ++ void testNativeResolver(); ++ void testNativeResolverCancellation(); ++ void testNativeResolverWorkerBoundAndDeadline(); ++ void testNativeResolverCoalescing(); ++ void testResolverOption(); +@@ -52,0 +73,297 @@ void AsyncNameResolverTest::testParseAsyncDNSServers() ++namespace { ++bool waitForNativeResolver(NativeAsyncResolver& resolver) ++{ ++ const auto deadline = ++ std::chrono::steady_clock::now() + std::chrono::seconds(5); ++ while (resolver.getStatus() == NativeAsyncResolver::STATUS_QUERYING) { ++ const auto now = std::chrono::steady_clock::now(); ++ if (now >= deadline) { ++ return false; ++ } ++ ++ fd_set rfds; ++ FD_ZERO(&rfds); ++ const sock_t socket = resolver.getSocket(); ++ FD_SET(socket, &rfds); ++ auto remaining = std::chrono::duration_cast( ++ deadline - now); ++ timeval timeout; ++ timeout.tv_sec = static_cast(remaining.count() / 1000000); ++ timeout.tv_usec = static_cast(remaining.count() % 1000000); ++#ifdef __MINGW32__ ++ const int nfds = 0; ++#else ++ const int nfds = static_cast(socket) + 1; ++#endif // __MINGW32__ ++ const int rv = select(nfds, &rfds, nullptr, nullptr, &timeout); ++ if (rv < 0) { ++ continue; ++ } ++ if (rv > 0 && FD_ISSET(socket, &rfds)) { ++ resolver.process(socket, static_cast(-1)); ++ } ++ } ++ return true; ++} ++ ++class BlockingResolverBackend : public NativeAsyncResolver::Backend { ++public: ++ NativeAsyncResolver::ResolutionResult resolve(const std::string&, int) override ++ { ++ std::unique_lock lock(mutex_); ++ ++started_; ++ ++active_; ++ if (active_ > maxActive_) { ++ maxActive_ = active_; ++ } ++ condition_.notify_all(); ++ condition_.wait(lock, [this]() { return released_; }); ++ --active_; ++ condition_.notify_all(); ++ ++ NativeAsyncResolver::ResolutionResult result; ++ result.addresses.push_back("198.51.100.7"); ++ return result; ++ } ++ ++ bool waitForStarted(size_t count, std::chrono::milliseconds timeout) ++ { ++ std::unique_lock lock(mutex_); ++ return condition_.wait_for(lock, timeout, ++ [this, count]() { return started_ >= count; }); ++ } ++ ++ void waitUntil(std::chrono::steady_clock::time_point deadline) ++ { ++ std::unique_lock lock(mutex_); ++ condition_.wait_until(lock, deadline); ++ } ++ ++ void release() ++ { ++ std::lock_guard lock(mutex_); ++ released_ = true; ++ condition_.notify_all(); ++ } ++ ++ size_t started() const ++ { ++ std::lock_guard lock(mutex_); ++ return started_; ++ } ++ ++ size_t maxActive() const ++ { ++ std::lock_guard lock(mutex_); ++ return maxActive_; ++ } ++ ++private: ++ mutable std::mutex mutex_; ++ std::condition_variable condition_; ++ size_t started_ = 0; ++ size_t active_ = 0; ++ size_t maxActive_ = 0; ++ bool released_ = false; ++}; ++ ++bool waitForNativeResolvers( ++ const std::vector& resolvers, ++ std::chrono::milliseconds timeout) ++{ ++ const auto deadline = std::chrono::steady_clock::now() + timeout; ++ for (;;) { ++ bool complete = true; ++ fd_set rfds; ++ FD_ZERO(&rfds); ++ sock_t maxSocket = static_cast(-1); ++ for (auto resolver : resolvers) { ++ if (resolver->getStatus() == NativeAsyncResolver::STATUS_QUERYING) { ++ complete = false; ++ const sock_t socket = resolver->getSocket(); ++ FD_SET(socket, &rfds); ++ if (socket > maxSocket) { ++ maxSocket = socket; ++ } ++ } ++ } ++ if (complete) { ++ return true; ++ } ++ const auto now = std::chrono::steady_clock::now(); ++ if (now >= deadline) { ++ return false; ++ } ++ const auto remaining = std::chrono::duration_cast( ++ deadline - now); ++ timeval wait; ++ wait.tv_sec = static_cast(remaining.count() / 1000000); ++ wait.tv_usec = static_cast(remaining.count() % 1000000); ++#ifdef __MINGW32__ ++ const int nfds = 0; ++#else ++ const int nfds = static_cast(maxSocket) + 1; ++#endif // __MINGW32__ ++ const int rv = select(nfds, &rfds, nullptr, nullptr, &wait); ++ if (rv < 0) { ++ continue; ++ } ++ if (rv == 0) { ++ return false; ++ } ++ for (auto resolver : resolvers) { ++ const sock_t socket = resolver->getSocket(); ++ if (FD_ISSET(socket, &rfds)) { ++ resolver->process(socket, static_cast(-1)); ++ } ++ } ++ } ++} ++} // namespace ++ ++void AsyncNameResolverTest::testNativeResolver() ++{ ++ NativeAsyncResolver resolver(AF_INET); ++ CPPUNIT_ASSERT_EQUAL((size_t)8, NativeAsyncResolver::workerCount()); ++ CPPUNIT_ASSERT_EQUAL((size_t)1024, NativeAsyncResolver::maxPendingJobs()); ++ ++ resolver.resolve("localhost", ++ std::chrono::steady_clock::now() + std::chrono::seconds(5)); ++ CPPUNIT_ASSERT(waitForNativeResolver(resolver)); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_SUCCESS, ++ resolver.getStatus()); ++ CPPUNIT_ASSERT(!resolver.getResolvedAddresses().empty()); ++} ++ ++void AsyncNameResolverTest::testNativeResolverCancellation() ++{ ++ NativeAsyncResolver resolver(AF_INET); ++ resolver.resolve("localhost", ++ std::chrono::steady_clock::now() + std::chrono::seconds(5)); ++ resolver.cancel(); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_READY, ++ resolver.getStatus()); ++ CPPUNIT_ASSERT(resolver.getResolvedAddresses().empty()); ++ ++ resolver.resolve("LOCALHOST", ++ std::chrono::steady_clock::now() + std::chrono::seconds(5)); ++ CPPUNIT_ASSERT(waitForNativeResolver(resolver)); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_SUCCESS, ++ resolver.getStatus()); ++} ++ ++void AsyncNameResolverTest::testNativeResolverWorkerBoundAndDeadline() ++{ ++ auto backend = std::make_shared(); ++ std::vector> owned; ++ std::vector resolvers; ++ owned.reserve(10); ++ resolvers.reserve(10); ++ ++ const auto firstDeadline = ++ std::chrono::steady_clock::now() + std::chrono::milliseconds(500); ++ for (size_t i = 0; i < 9; ++i) { ++ owned.emplace_back( ++ std::unique_ptr( ++ new NativeAsyncResolver(AF_INET, backend))); ++ resolvers.push_back(owned.back().get()); ++ const auto deadline = i == 0 ++ ? firstDeadline ++ : std::chrono::steady_clock::now() + ++ std::chrono::seconds(5); ++ owned.back()->resolve("stalled-" + std::to_string(i) + ".example", ++ deadline); ++ } ++ ++ CPPUNIT_ASSERT(backend->waitForStarted(8, std::chrono::seconds(2))); ++ CPPUNIT_ASSERT_EQUAL((size_t)8, backend->maxActive()); ++ CPPUNIT_ASSERT_EQUAL((size_t)8, backend->started()); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_QUERYING, ++ owned.back()->getStatus()); ++ ++ backend->waitUntil(firstDeadline + std::chrono::milliseconds(20)); ++ owned.front()->process(static_cast(-1), static_cast(-1)); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_ERROR, ++ owned.front()->getStatus()); ++ CPPUNIT_ASSERT_EQUAL(std::string("native resolver deadline exceeded"), ++ owned.front()->getError()); ++ ++ // The expired request must release its coalescing key even though the ++ // underlying getaddrinfo() worker is still blocked. A lookup after a route ++ // change owns fresh work and cannot adopt the stale result. ++ owned.emplace_back(std::unique_ptr( ++ new NativeAsyncResolver(AF_INET, backend))); ++ resolvers.push_back(owned.back().get()); ++ owned.back()->resolve("stalled-0.example", ++ std::chrono::steady_clock::now() + ++ std::chrono::seconds(5)); ++ backend->release(); ++ CPPUNIT_ASSERT(waitForNativeResolvers(resolvers, std::chrono::seconds(5))); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_ERROR, ++ owned.front()->getStatus()); ++ CPPUNIT_ASSERT_EQUAL((size_t)0, ++ owned.front()->getResolvedAddresses().size()); ++ CPPUNIT_ASSERT_EQUAL((size_t)10, backend->started()); ++ CPPUNIT_ASSERT(backend->maxActive() <= NativeAsyncResolver::WORKER_COUNT); ++} ++ ++void AsyncNameResolverTest::testNativeResolverCoalescing() ++{ ++ auto backend = std::make_shared(); ++ NativeAsyncResolver first(AF_INET, backend); ++ NativeAsyncResolver second(AF_INET, backend); ++ CPPUNIT_ASSERT_EQUAL(first.getSocket(), second.getSocket()); ++ const auto deadline = ++ std::chrono::steady_clock::now() + std::chrono::seconds(5); ++ first.resolve("COALESCED.example", deadline); ++ second.resolve("coalesced.example", deadline); ++ ++ CPPUNIT_ASSERT(backend->waitForStarted(1, std::chrono::seconds(2))); ++ backend->release(); ++ std::vector resolvers = {&first, &second}; ++ CPPUNIT_ASSERT(waitForNativeResolvers(resolvers, std::chrono::seconds(5))); ++ CPPUNIT_ASSERT_EQUAL((size_t)1, backend->started()); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_SUCCESS, ++ first.getStatus()); ++ CPPUNIT_ASSERT_EQUAL(NativeAsyncResolver::STATUS_SUCCESS, ++ second.getStatus()); ++ CPPUNIT_ASSERT_EQUAL((size_t)1024, ++ NativeAsyncResolver::maxPendingSubscribers()); ++} ++ ++void AsyncNameResolverTest::testResolverOption() ++{ ++ const auto& optionParser = OptionParser::getInstance(); ++ const OptionHandler* handler = optionParser->find(PREF_DNS_RESOLVER); ++ CPPUNIT_ASSERT(handler); ++ CPPUNIT_ASSERT_EQUAL(std::string("native-async"), ++ handler->getDefaultValue()); ++ ++ Option option; ++ optionParser->parseDefaultValues(option); ++ CPPUNIT_ASSERT_EQUAL(V_NATIVE_ASYNC, option.get(PREF_DNS_RESOLVER)); ++ const OptionHandler* policyHandler = ++ optionParser->find(PREF_NETWORK_TARGET_POLICY); ++ CPPUNIT_ASSERT(policyHandler); ++ CPPUNIT_ASSERT_EQUAL(std::string("firelink-v1"), ++ policyHandler->getDefaultValue()); ++ CPPUNIT_ASSERT_EQUAL(V_FIRELINK_V1, ++ option.get(PREF_NETWORK_TARGET_POLICY)); ++ ++ KeyVals values; ++ values.emplace_back(PREF_DNS_RESOLVER->k, V_NATIVE_ASYNC); ++ optionParser->parse(option, values); ++ CPPUNIT_ASSERT_EQUAL(V_NATIVE_ASYNC, option.get(PREF_DNS_RESOLVER)); ++ ++#ifdef HAVE_LIBCARES ++ values.front().second = V_CARES; ++ optionParser->parse(option, values); ++ CPPUNIT_ASSERT_EQUAL(V_CARES, option.get(PREF_DNS_RESOLVER)); ++#endif // HAVE_LIBCARES ++ ++ values.front().first = PREF_NETWORK_TARGET_POLICY->k; ++ values.front().second = V_NONE; ++ optionParser->parse(option, values); ++ CPPUNIT_ASSERT_EQUAL(V_NONE, option.get(PREF_NETWORK_TARGET_POLICY)); ++} ++ +diff --git a/test/DHTMessageDispatcherImplTest.cc b/test/DHTMessageDispatcherImplTest.cc +new file mode 100644 +index 00000000..7b6f9da5 +--- /dev/null ++++ b/test/DHTMessageDispatcherImplTest.cc +@@ -0,0 +1,69 @@ ++#include "DHTMessageDispatcherImpl.h" ++ ++#include ++ ++#include "DHTMessageTracker.h" ++#include "DHTMessageTrackerEntry.h" ++#include "DHTMessageEntry.h" ++#include "DHTMessageCallback.h" ++#include "DHTNode.h" ++#include "MockDHTMessage.h" ++ ++namespace aria2 { ++ ++namespace { ++class CountingDHTMessage : public MockDHTMessage { ++public: ++ CountingDHTMessage(const std::shared_ptr& localNode, ++ const std::shared_ptr& remoteNode, ++ size_t* sends) ++ : MockDHTMessage(localNode, remoteNode), sends_(sends) ++ { ++ } ++ ++ bool send() CXX11_OVERRIDE ++ { ++ ++*sends_; ++ return true; ++ } ++ ++private: ++ size_t* sends_; ++}; ++} // namespace ++ ++class DHTMessageDispatcherImplTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST_SUITE(DHTMessageDispatcherImplTest); ++ CPPUNIT_TEST(testRouteOwnedPrivateBootstrapGrant); ++ CPPUNIT_TEST_SUITE_END(); ++ ++public: ++ void testRouteOwnedPrivateBootstrapGrant(); ++}; ++ ++CPPUNIT_TEST_SUITE_REGISTRATION(DHTMessageDispatcherImplTest); ++ ++void DHTMessageDispatcherImplTest::testRouteOwnedPrivateBootstrapGrant() ++{ ++ auto tracker = std::make_shared(); ++ DHTMessageDispatcherImpl dispatcher(tracker, true); ++ auto localNode = std::make_shared(); ++ auto remoteNode = std::make_shared(); ++ remoteNode->setIPAddress("10.0.0.7"); ++ remoteNode->setPort(6881); ++ ++ size_t blockedSends = 0; ++ dispatcher.addMessageToQueue(make_unique( ++ localNode, remoteNode, &blockedSends)); ++ dispatcher.sendMessages(); ++ CPPUNIT_ASSERT_EQUAL((size_t)0, blockedSends); ++ ++ dispatcher.allowRouteOwnedAddress("10.0.0.7"); ++ size_t allowedSends = 0; ++ dispatcher.addMessageToQueue(make_unique( ++ localNode, remoteNode, &allowedSends)); ++ dispatcher.sendMessages(); ++ CPPUNIT_ASSERT_EQUAL((size_t)1, allowedSends); ++} ++ ++} // namespace aria2 +diff --git a/test/DHTMessageTrackerEntryTest.cc b/test/DHTMessageTrackerEntryTest.cc +index 9459f548..5c7760b5 100644 +--- a/test/DHTMessageTrackerEntryTest.cc ++++ b/test/DHTMessageTrackerEntryTest.cc +@@ -52,0 +53,13 @@ void DHTMessageTrackerEntryTest::testMatch() ++ ++ // Test translated IP match under TUN / Fake-IP / NAT rewrites ++ auto node3 = std::make_shared(); ++ node3->setIPAddress("198.18.0.5"); ++ node3->setPort(6881); ++ auto msg3 = make_unique(localNode, node3); ++ DHTMessageTrackerEntry entryTun(msg3->getRemoteNode(), ++ msg3->getTransactionID(), ++ msg3->getMessageType(), 30_s); ++ // Response arrives from upstream real IP 185.125.190.1 on the same port with same transactionID ++ CPPUNIT_ASSERT(entryTun.match(msg3->getTransactionID(), ++ "185.125.190.1", 6881)); ++ CPPUNIT_ASSERT_EQUAL(std::string("185.125.190.1"), node3->getIPAddress()); +diff --git a/test/DownloadHelperTest.cc b/test/DownloadHelperTest.cc +index 6a8ab8d2..93ea148e 100644 +--- a/test/DownloadHelperTest.cc ++++ b/test/DownloadHelperTest.cc +@@ -26,0 +27 @@ class DownloadHelperTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST(testCreateRequestGroupForUriRejectsMalformedNetworkTarget); +@@ -50,0 +52 @@ public: ++ void testCreateRequestGroupForUriRejectsMalformedNetworkTarget(); +@@ -133,0 +136,11 @@ void DownloadHelperTest::testCreateRequestGroupForUri() ++void DownloadHelperTest::testCreateRequestGroupForUriRejectsMalformedNetworkTarget() ++{ ++ option_->put(PREF_NETWORK_TARGET_POLICY, V_FIRELINK_V1); ++ std::vector> result; ++ const std::vector uris{"https://[invalid"}; ++ CPPUNIT_ASSERT_THROW( ++ createRequestGroupForUri(result, option_, uris, false, false, true), ++ DlAbortEx); ++ CPPUNIT_ASSERT(result.empty()); ++} ++ +diff --git a/test/Makefile.am b/test/Makefile.am +index 49c6211f..664caa1f 100644 +--- a/test/Makefile.am ++++ b/test/Makefile.am +@@ -172,0 +173 @@ aria2c_SOURCES += BtAllowedFastMessageTest.cc\ ++ DHTMessageDispatcherImplTest.cc\ +@@ -233,0 +235 @@ aria2c_SOURCES += AsyncNameResolverTest.cc ++aria2c_SOURCES += NetworkTargetPolicyTest.cc +diff --git a/test/NetworkTargetPolicyTest.cc b/test/NetworkTargetPolicyTest.cc +new file mode 100644 +index 00000000..79939c3c +--- /dev/null ++++ b/test/NetworkTargetPolicyTest.cc +@@ -0,0 +1,148 @@ ++#include "NetworkTargetPolicy.h" ++ ++#include ++ ++#include "Request.h" ++ ++namespace aria2 { ++namespace network { ++ ++class NetworkTargetPolicyTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST_SUITE(NetworkTargetPolicyTest); ++ CPPUNIT_TEST(testPolicyName); ++ CPPUNIT_TEST(testLocalNames); ++ CPPUNIT_TEST(testSpecialIpv4Literals); ++ CPPUNIT_TEST(testSpecialIpv6Literals); ++ CPPUNIT_TEST(testHostnamesAndGlobalLiterals); ++ CPPUNIT_TEST(testMalformedBracketedHosts); ++ CPPUNIT_TEST(testUriAndRedirectVectors); ++ CPPUNIT_TEST_SUITE_END(); ++ ++public: ++ void testPolicyName(); ++ void testLocalNames(); ++ void testSpecialIpv4Literals(); ++ void testSpecialIpv6Literals(); ++ void testHostnamesAndGlobalLiterals(); ++ void testMalformedBracketedHosts(); ++ void testUriAndRedirectVectors(); ++}; ++ ++CPPUNIT_TEST_SUITE_REGISTRATION(NetworkTargetPolicyTest); ++ ++namespace { ++void assertRejected(const std::string& value) ++{ ++ std::string reason; ++ CPPUNIT_ASSERT_MESSAGE(value, !validateHost(value, &reason)); ++ CPPUNIT_ASSERT(!reason.empty()); ++} ++ ++void assertAccepted(const std::string& value) ++{ ++ std::string reason; ++ CPPUNIT_ASSERT_MESSAGE(reason, validateHost(value, &reason)); ++} ++} // namespace ++ ++void NetworkTargetPolicyTest::testPolicyName() ++{ ++ CPPUNIT_ASSERT(isFirelinkPolicy("firelink-v1")); ++ CPPUNIT_ASSERT(!isFirelinkPolicy("none")); ++ CPPUNIT_ASSERT(!isFirelinkPolicy("firelink-v2")); ++} ++ ++void NetworkTargetPolicyTest::testLocalNames() ++{ ++ assertRejected("localhost"); ++ assertRejected("LOCALHOST."); ++ assertRejected("localhost.localdomain"); ++ assertRejected("localhost6"); ++ assertRejected("ip6-loopback"); ++ assertRejected("ip6-allnodes"); ++ assertRejected("download.localhost"); ++ assertRejected("printer.local"); ++ assertRejected("broadcasthost"); ++ assertRejected("local"); ++ assertRejected("127.0.0.1."); ++ assertRejected("127.1"); ++ assertRejected("2130706433"); ++ assertRejected("0x7f000001"); ++ assertRejected("0177.0.0.1"); ++ assertRejected("0"); ++ assertRejected("fe80::1%lo0"); ++} ++ ++void NetworkTargetPolicyTest::testSpecialIpv4Literals() ++{ ++ const char* addresses[] = { ++ "0.0.0.0", "0.0.0.1", "10.0.0.1", "100.64.0.1", ++ "127.0.0.1", ++ "169.254.1.1", "172.16.0.1", "192.168.1.1", "224.0.0.1", ++ "255.255.255.255"}; ++ for (const char* address : addresses) { ++ assertRejected(address); ++ } ++} ++ ++void NetworkTargetPolicyTest::testMalformedBracketedHosts() ++{ ++ assertRejected("[not-an-ip]"); ++ assertRejected("[2001:db8::1"); ++ assertRejected("2001:db8::1]"); ++ assertRejected(".localhost"); ++ assertRejected(".local"); ++} ++ ++void NetworkTargetPolicyTest::testSpecialIpv6Literals() ++{ ++ const char* addresses[] = { ++ "::", "::1", "::ffff:127.0.0.1", ++ "::ffff:192.168.1.1", "fc00::1", "fe80::1", ++ "fec0::1", "ff02::1"}; ++ for (const char* address : addresses) { ++ assertRejected(address); ++ } ++} ++ ++void NetworkTargetPolicyTest::testHostnamesAndGlobalLiterals() ++{ ++ // This must not perform DNS or inspect a potentially synthetic/private ++ // answer supplied by the active OS, TUN, or proxy route. ++ assertAccepted("private-looking-host.example"); ++ assertAccepted("service.internal"); ++ assertAccepted("192.0.1.1"); ++ assertAccepted("192.0.2.1"); ++ assertAccepted("198.51.1.1"); ++ assertAccepted("198.51.100.1"); ++ assertAccepted("8.8.8.8"); ++ assertAccepted("::ffff:8.8.8.8"); ++ assertAccepted("::8.8.8.8"); ++ assertAccepted("2001:db8::1"); ++ assertAccepted("2001:4860:4860::8888"); ++} ++ ++void NetworkTargetPolicyTest::testUriAndRedirectVectors() ++{ ++ std::string reason; ++ CPPUNIT_ASSERT(validateUri("https://user:password@service.example/file", ++ &reason)); ++ CPPUNIT_ASSERT(validateUri("ftp://service.example/file", &reason)); ++ CPPUNIT_ASSERT(validateUri("sftp://service.example/file", &reason)); ++ CPPUNIT_ASSERT(!validateUri("file://service.example/file", &reason)); ++ CPPUNIT_ASSERT(!validateUri("https://127.0.0.1/file", &reason)); ++ CPPUNIT_ASSERT(!validateUri("https://0.0.0.1/file", &reason)); ++ CPPUNIT_ASSERT(!validateUri("https://[::ffff:127.0.0.1]/file", &reason)); ++ CPPUNIT_ASSERT(!validateUri("https://[fe80::1%25lo0]/file", &reason)); ++ ++ CPPUNIT_ASSERT(validateUri("https://service.example/redirect", &reason)); ++ CPPUNIT_ASSERT(!validateUri("https://localhost/redirect", &reason)); ++ ++ Request request; ++ CPPUNIT_ASSERT(request.setUri("https://service.example/start")); ++ CPPUNIT_ASSERT(request.redirectUri("https://127.0.0.1/private")); ++ CPPUNIT_ASSERT(!validateUri(request.getCurrentUri(), &reason)); ++} ++ ++} // namespace network ++} // namespace aria2 +diff --git a/test/RequestTest.cc b/test/RequestTest.cc +index 24abf91f..f62a1557 100644 +--- a/test/RequestTest.cc ++++ b/test/RequestTest.cc +@@ -27,0 +28 @@ class RequestTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST(testRouteResolvedAddresses); +@@ -43,0 +45 @@ public: ++ void testRouteResolvedAddresses(); +@@ -272,0 +275,39 @@ void RequestTest::testGetURIHost() ++void RequestTest::testRouteResolvedAddresses() ++{ ++ Request req; ++ std::vector resolved{"2001:db8::1", "198.51.100.1"}; ++ std::vector remaining; ++ ++ req.setRouteResolvedAddresses("example.test", 443, resolved); ++ CPPUNIT_ASSERT( ++ req.getRouteResolvedAddresses("example.test", 443, remaining)); ++ CPPUNIT_ASSERT(remaining == resolved); ++ ++ remaining.clear(); ++ CPPUNIT_ASSERT(!req.markRouteResolvedAddressBad("other.test", "2001:db8::1", ++ 443)); ++ CPPUNIT_ASSERT( ++ req.getRouteResolvedAddresses("example.test", 443, remaining)); ++ CPPUNIT_ASSERT(remaining == resolved); ++ ++ remaining.clear(); ++ CPPUNIT_ASSERT( ++ req.markRouteResolvedAddressBad("example.test", "2001:db8::1", 443)); ++ CPPUNIT_ASSERT( ++ req.getRouteResolvedAddresses("example.test", 443, remaining)); ++ CPPUNIT_ASSERT(remaining.size() == 1); ++ CPPUNIT_ASSERT_EQUAL(std::string("198.51.100.1"), remaining.front()); ++ ++ CPPUNIT_ASSERT(!req.markRouteResolvedAddressBad("example.test", ++ "198.51.100.1", 443)); ++ remaining.clear(); ++ CPPUNIT_ASSERT(!req.getRouteResolvedAddresses("example.test", 443, ++ remaining)); ++ ++ req.setRouteResolvedAddresses("example.test", 443, resolved); ++ req.redirectUri("http://new.example.test/file"); ++ remaining.clear(); ++ CPPUNIT_ASSERT(!req.getRouteResolvedAddresses("example.test", 443, ++ remaining)); ++} ++ +diff --git a/test/RpcMethodTest.cc b/test/RpcMethodTest.cc +index 44bae5fe..d4dbd499 100644 +--- a/test/RpcMethodTest.cc ++++ b/test/RpcMethodTest.cc +@@ -23,0 +24,3 @@ ++#include "FirelinkCapabilities.h" ++#include "FileAllocationMan.h" ++#include "StreamFileAllocationEntry.h" +@@ -64,0 +68 @@ class RpcMethodTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST(testAllocationTelemetry); +@@ -134,0 +139 @@ public: ++ void testAllocationTelemetry(); +@@ -780,0 +786,35 @@ void addTorrent(const std::string& torrentFile, ++void RpcMethodTest::testAllocationTelemetry() ++{ ++ e_->setFileAllocationMan(make_unique()); ++ addUri("http://1/", e_); ++ const auto group = getReservedGroup(e_->getRequestGroupMan().get(), 0); ++ group->setDownloadContext(std::make_shared(1024, 4096, A2_TEST_OUT_DIR "/allocation")); ++ group->initPieceStorage(); ++ auto read = [&](bool requestKey) { ++ TellStatusRpcMethod method; ++ auto req = createReq(TellStatusRpcMethod::getMethodName()); ++ req.params->append(GroupId::toHex(group->getGID())); ++ auto keys = List::g(); ++ keys->append(requestKey ? "fileAllocationPending" : "gid"); ++ req.params->append(std::move(keys)); ++ return method.execute(std::move(req), e_.get()); ++ }; ++ auto pending = [&]() { ++ auto result = read(true); ++ CPPUNIT_ASSERT_EQUAL(0, result.code); ++ const auto value = downcast(downcast(result.param)->get("fileAllocationPending")); ++ CPPUNIT_ASSERT(value); ++ return value->val(); ++ }; ++ CPPUNIT_ASSERT(!pending()); ++ auto& manager = e_->getFileAllocationMan(); ++ manager->pushEntry(make_unique(group.get())); ++ CPPUNIT_ASSERT(pending()); ++ manager->pickNext(); ++ CPPUNIT_ASSERT(pending()); ++ auto filtered = read(false); ++ CPPUNIT_ASSERT(!downcast(filtered.param)->get("fileAllocationPending")); ++ manager->dropPickedEntry(); ++ CPPUNIT_ASSERT(!pending()); ++} ++ +@@ -905,0 +946,3 @@ void RpcMethodTest::testGetVersion() ++ e_->getOption()->put(PREF_ASYNC_DNS, A2_V_TRUE); ++ e_->getOption()->put(PREF_DNS_RESOLVER, V_NATIVE_ASYNC); ++ e_->getOption()->put(PREF_NETWORK_TARGET_POLICY, V_FIRELINK_V1); +@@ -912,0 +956,26 @@ void RpcMethodTest::testGetVersion() ++ CPPUNIT_ASSERT_EQUAL(std::string(firelink::REVISION), ++ getString(resParams, "firelinkRevision")); ++ CPPUNIT_ASSERT_EQUAL(std::string("native-async"), ++ getString(resParams, "firelinkDnsResolver")); ++ CPPUNIT_ASSERT_EQUAL(std::string("firelink-v1"), ++ getString(resParams, "firelinkNetworkTargetPolicy")); ++ CPPUNIT_ASSERT_EQUAL(std::string(firelink::NETWORK_TARGET_POLICY_DIGEST), ++ getString(resParams, ++ "firelinkNetworkTargetPolicyDigest")); ++ CPPUNIT_ASSERT(downcast( ++ resParams->get("firelinkNetworkTargetPolicyEnforced")) ++ ->val()); ++ const List* resolverCapabilities = ++ downcast(resParams->get("firelinkDnsResolvers")); ++ CPPUNIT_ASSERT(resolverCapabilities); ++ CPPUNIT_ASSERT_EQUAL((size_t)1 + ++#ifdef HAVE_LIBCARES ++ 1, ++#else ++ 0, ++#endif // HAVE_LIBCARES ++ resolverCapabilities->size()); ++ const List* policyCapabilities = ++ downcast(resParams->get("firelinkNetworkTargetPolicies")); ++ CPPUNIT_ASSERT(policyCapabilities); ++ CPPUNIT_ASSERT_EQUAL((size_t)2, policyCapabilities->size()); +@@ -942,0 +1012 @@ void RpcMethodTest::testGatherStoppedDownload() ++ CPPUNIT_ASSERT(!downcast(entry->get("fileAllocationPending"))->val()); +diff --git a/test/UDPTrackerClientTest.cc b/test/UDPTrackerClientTest.cc +index fc2f34e6..472defab 100644 +--- a/test/UDPTrackerClientTest.cc ++++ b/test/UDPTrackerClientTest.cc +@@ -21,0 +22 @@ class UDPTrackerClientTest : public CppUnit::TestFixture { ++ CPPUNIT_TEST(testTranslatedAddressResponse); +@@ -31,0 +33 @@ public: ++ void testTranslatedAddressResponse(); +@@ -470,0 +473,51 @@ void UDPTrackerClientTest::testTimeout() ++void UDPTrackerClientTest::testTranslatedAddressResponse() ++{ ++ ssize_t rv; ++ unsigned char data[100]; ++ std::string remoteAddr; ++ uint16_t remotePort; ++ Timer now; ++ UDPTrackerClient tr; ++ std::shared_ptr recvReq; ++ ++ // Simulate request sent to proxy/fake-IP address: 198.18.0.5:6969 ++ std::shared_ptr req = ++ createAnnounce("198.18.0.5", 6969, 0); ++ tr.addRequest(req); ++ ++ rv = tr.createRequest(data, sizeof(data), remoteAddr, remotePort, now); ++ CPPUNIT_ASSERT_EQUAL((ssize_t)16, rv); ++ CPPUNIT_ASSERT_EQUAL(std::string("198.18.0.5"), remoteAddr); ++ CPPUNIT_ASSERT_EQUAL((uint16_t)6969, remotePort); ++ CPPUNIT_ASSERT_EQUAL((int)UDPT_ACT_CONNECT, ++ (int)bittorrent::getIntParam(data, 8)); ++ uint32_t connectTxId = bittorrent::getIntParam(data, 12); ++ tr.requestSent(now); ++ ++ // Tracker reply arrives from upstream real IP (185.125.190.1:6969) under TUN translation ++ uint64_t connectionId = 0x1122334455667788ULL; ++ rv = createConnectReply(data, sizeof(data), connectionId, connectTxId); ++ rv = tr.receiveReply(recvReq, data, rv, "185.125.190.1", 6969, now); ++ CPPUNIT_ASSERT_EQUAL(0, (int)rv); ++ CPPUNIT_ASSERT(recvReq != nullptr); ++ CPPUNIT_ASSERT_EQUAL(std::string("198.18.0.5"), recvReq->remoteAddr); ++ ++ // Next, createRequest generates ANNOUNCE to 198.18.0.5 using cached connectionId ++ rv = tr.createRequest(data, sizeof(data), remoteAddr, remotePort, now); ++ CPPUNIT_ASSERT(rv > 0); ++ CPPUNIT_ASSERT_EQUAL(std::string("198.18.0.5"), remoteAddr); ++ CPPUNIT_ASSERT_EQUAL((uint16_t)6969, remotePort); ++ CPPUNIT_ASSERT_EQUAL((int)UDPT_ACT_ANNOUNCE, ++ (int)bittorrent::getIntParam(data, 8)); ++ uint32_t announceTxId = bittorrent::getIntParam(data, 12); ++ tr.requestSent(now); ++ ++ // Announce reply arrives with translated IP 185.125.190.1:6969 ++ rv = createAnnounceReply(data, sizeof(data), announceTxId, 2); ++ rv = tr.receiveReply(recvReq, data, rv, "185.125.190.1", 6969, now); ++ CPPUNIT_ASSERT_EQUAL(0, (int)rv); ++ CPPUNIT_ASSERT(recvReq != nullptr); ++ CPPUNIT_ASSERT_EQUAL((int)UDPT_STA_COMPLETE, recvReq->state); ++ CPPUNIT_ASSERT_EQUAL((size_t)2, recvReq->reply->peers.size()); ++} ++ diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index 774c4d1..23ee88a 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -145,6 +145,7 @@ function writePayloadManifest() { version: source.version, url: source.url || source.sourceUrl, sha256: source.sha256 || source.sourceSha256, + ...(source.buildFromSource ? { patchSha256: source.patchSha256, allocationTelemetry: true } : {}), ...(name === 'aria2c' && source.firelinkRouteContract ? { firelinkRouteContract: source.firelinkRouteContract } : {}) @@ -194,7 +195,25 @@ try { copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg'); const aria2 = await download('aria2c', targetSources.aria2c); - copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c'); + const aria2Source = targetSources.aria2c; + if (aria2Source.buildFromSource !== true || aria2Source.allocationTelemetry !== true) { + throw new Error('Aria2 provisioning requires the allocation telemetry source build.'); + } + const patchFile = path.join(repoRoot, aria2Source.patch); + if (sha256(patchFile) !== aria2Source.patchSha256) throw new Error('Aria2 source patch checksum mismatch'); + const sourceRoots = fs.readdirSync(aria2, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && fs.existsSync(path.join(aria2, entry.name, 'configure.ac'))) + .map(entry => path.join(aria2, entry.name)); + if (sourceRoots.length !== 1) throw new Error('Aria2 archive must contain exactly one source root'); + const [sourceRoot] = sourceRoots; + const bash = isWindows ? path.join(process.env.FIRELINK_MSYS2_ROOT || 'C:/msys64', 'usr/bin/bash.exe') : 'bash'; + await execFileAsync(bash, [path.join(repoRoot, 'scripts/aria2/build.sh').replaceAll('\\', '/'), sourceRoot, patchFile], { + signal: provisioningAbortController.signal, + env: { ...process.env, ...(isWindows ? { MSYSTEM: 'MINGW64' } : {}) }, + maxBuffer: 32 * 1024 * 1024, + timeout: 30 * 60 * 1000, + }); + copyExecutable(path.join(sourceRoot, 'firelink-build', 'src', `aria2c${executableSuffix}`), 'aria2c'); writePayloadManifest(); throwIfProvisioningAborted(); diff --git a/scripts/smoke-packaged-app.js b/scripts/smoke-packaged-app.js index 3d48b3f..2431625 100755 --- a/scripts/smoke-packaged-app.js +++ b/scripts/smoke-packaged-app.js @@ -2,6 +2,7 @@ import { execFileSync, spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; function argValue(name) { const index = process.argv.indexOf(name); @@ -24,12 +25,16 @@ const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0 ? Math.min(stabilityMsValue, MAX_STABILITY_MS) : 5000; const READY_PORT_TIMEOUT_MS = 500; +// Portable-package checks intentionally inspect their disposable bundle's +// data directory. Every other smoke run gets its own disposable profile. +const smokeStorageRoot = assertPortableData ? null : fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-smoke-'))); const child = spawn(executable, [], { cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(), detached: process.platform !== 'win32', env: { ...process.env, FIRELINK_SMOKE_TEST: '1', + FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '', WEBKIT_DISABLE_COMPOSITING_MODE: '1', GDK_BACKEND: 'x11', }, @@ -401,5 +406,7 @@ try { if (!await terminateChild()) { console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.'); process.exitCode = 1; + } else if (smokeStorageRoot) { + fs.rmSync(smokeStorageRoot, { recursive: true, force: true }); } } diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index bdb3f71..b04b3a6 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -12,6 +12,7 @@ import { import { ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, assertAria2Baseline, + assertAria2AllocationCapabilities, } from './aria2-route-contract.js'; const __filename = fileURLToPath(import.meta.url); @@ -569,6 +570,7 @@ if (canExecuteTarget) { const resp = JSON.parse(result.data); if (resp?.result?.version) { assertAria2Baseline(resp.result); + assertAria2AllocationCapabilities(resp.result); ok(`aria2 RPC version: ${resp.result.version}`); } else { fail(`aria2 RPC unexpected response: ${result.data}`); diff --git a/src-tauri/binaries/aria2c-aarch64-apple-darwin b/src-tauri/binaries/aria2c-aarch64-apple-darwin index ad5d046..3e2e3a9 100755 Binary files a/src-tauri/binaries/aria2c-aarch64-apple-darwin and b/src-tauri/binaries/aria2c-aarch64-apple-darwin differ diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 642ee83..c97c1b7 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; const DATABASE_NAME: &str = "firelink.sqlite"; const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; -const CURRENT_SCHEMA_VERSION: i64 = 3; +const CURRENT_SCHEMA_VERSION: i64 = 4; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; // Development builds are a different executable identity from the packaged @@ -227,6 +227,13 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), .map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?; } + if from_version < 4 { + transaction.execute_batch("CREATE TABLE download_removal_jobs ( + id TEXT PRIMARY KEY, data TEXT NOT NULL + ); + CREATE TABLE download_removal_assets (id TEXT PRIMARY KEY, data TEXT NOT NULL);").map_err(|error| format!("failed to migrate removal jobs: {error}"))?; + } + transaction .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .map_err(|error| format!("failed to update database schema version: {error}"))?; @@ -1582,12 +1589,17 @@ fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), Strin fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> { transaction - .execute("DELETE FROM downloads", []) + .execute("DELETE FROM downloads WHERE id NOT IN (SELECT id FROM download_removal_jobs)", []) .map_err(|error| format!("failed to clear downloads: {error}"))?; for data in downloads { let value: Value = serde_json::from_str(data) .map_err(|error| format!("failed to decode download: {error}"))?; let id = required_string(&value, "id")?; + // Native removal intent and terminal tombstones outrank renderer snapshots. + if transaction.query_row("SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", [id], |row| row.get::<_, bool>(0)) + .map_err(|error| error.to_string())? { + continue; + } let status = required_string(&value, "status")?; let queue_id = value.get("queueId").and_then(Value::as_str); transaction @@ -2400,6 +2412,37 @@ mod tests { use serde_json::json; use tempfile::TempDir; + #[test] + fn removal_jobs_preserve_intent_and_prevent_stale_snapshot_resurrection() { + let root = TempDir::new().unwrap(); + let db = init_at_path(root.path()).unwrap(); + let mut connection = db.lock().unwrap(); + let original = r#"[{"id":"remove-me","status":"paused","fileName":"payload"},{"id":"keep-me","status":"paused"}]"#; + replace_downloads(&mut connection, original, false).unwrap(); + connection.execute("INSERT INTO download_removal_jobs VALUES ('remove-me', ?1)", + [r#"{"id":"remove-me","deleteAssets":true,"phase":"pending","error":null}"#]).unwrap(); + replace_downloads(&mut connection, r#"[{"id":"keep-me","status":"completed"}]"#, false).unwrap(); + assert_eq!(load_downloads(&connection).unwrap().len(), 2); + mutate_download(&mut connection, "remove-me", false, |row| { + row.insert("status".into(), json!("completed")); + Ok(()) + }).unwrap(); + replace_downloads(&mut connection, original, false).unwrap(); + let completed: String = connection.query_row("SELECT status FROM downloads WHERE id='remove-me'", [], |row| row.get(0)).unwrap(); + assert_eq!(completed, "completed"); + connection.execute("DELETE FROM downloads WHERE id='remove-me'", []).unwrap(); + connection.execute("UPDATE download_removal_jobs SET data=?1 WHERE id='remove-me'", + [r#"{"id":"remove-me","deleteAssets":true,"phase":"completed","error":null}"#]).unwrap(); + replace_downloads(&mut connection, original, false).unwrap(); + let saved = load_downloads(&connection).unwrap(); + assert_eq!(saved.len(), 1); + assert!(saved[0].contains("keep-me")); + drop(connection); + drop(db); + let reopened = init_at_path(root.path()).unwrap(); + assert_eq!(load_downloads(&reopened.lock().unwrap()).unwrap().len(), 1); + } + #[test] fn site_login_settings_update_preserves_envelope_without_password() { let original = json!({ diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 0c5c799..938427f 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -1011,3 +1011,20 @@ impl DownloadStateEvent { (error, error_kind) } } + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct DownloadRemovalJob { + pub id: String, + #[serde(default)] + pub revision: u32, + pub delete_assets: bool, + pub phase: DownloadRemovalPhase, + pub error: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum DownloadRemovalPhase { Pending, Running, Failed, Completed } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d957033..cc11ffb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ #![allow(unexpected_cfgs)] +mod removal_jobs; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use futures_util::StreamExt; @@ -5253,6 +5254,7 @@ async fn pause_download( log::info!("pause_download called for id: {}", id); let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; let active_kind = state.queue_manager.active_kind(&id).await; let registered_lifecycle_generation = state .queue_manager @@ -5551,6 +5553,7 @@ async fn resume_download( return Err("Queue id cannot be empty".to_string()); } let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else { log::info!( "aria2 resume [{}]: no mapped gid; re-enqueue is permitted", @@ -6127,6 +6130,42 @@ async fn remove_download( expected_lifecycle_generation: Option, ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; + removal_jobs::ensure_not_removing(&app_handle, &id)?; + remove_download_inner(app_handle, state, id, delete_assets, preserve_resumable, + asset_removal_policy, expected_lifecycle_generation, false).await +} + +fn removal_status_has_completed_payload(status: &serde_json::Value) -> bool { + status.get("status").and_then(serde_json::Value::as_str) == Some("complete") + || status.get("seeder").is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true")) +} + +async fn removal_payload_completed(port: u16, secret: &str, gid: &str) -> Result { + match rpc_call(port, secret, "aria2.tellStatus", + serde_json::json!([gid, ["status", "seeder", "totalLength", "completedLength"]])).await { + Ok(status) => { + let bytes = |key: &str| status.get(key).and_then(serde_json::Value::as_str) + .and_then(|value| value.parse::().ok()); + Ok(removal_status_has_completed_payload(&status) + || matches!((bytes("totalLength"), bytes("completedLength")), (Some(total), Some(done)) if total > 0 && done == total)) + } + // A purged result cannot prove that the payload was unfinished. + // Preserve it through Trash instead of permitting irreversible cleanup. + Err(error) if aria2_gid_not_found(&error) => Ok(true), + Err(error) => Err(error), + } +} + +async fn remove_download_inner( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + id: String, + delete_assets: bool, + preserve_resumable: Option, + asset_removal_policy: Option, + expected_lifecycle_generation: Option, + durable_removal_job: bool, +) -> Result<(), String> { log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); // The permanent policy is deliberately opt-in and is resolved against the @@ -6144,21 +6183,36 @@ async fn remove_download( }) .transpose()?; let mut control_guard = Some(state.queue_manager.acquire_aria2_control(&id).await); + if !durable_removal_job { + removal_jobs::ensure_not_removing(&app_handle, &id)?; + } let mut cleanup_control_guard: Option = None; // Classify the removal from the durable row only after taking the same // lifecycle guard that protects stopping the native owner. This prevents // a completed/unfinished decision from racing a terminal transition or a // replacement lifecycle for the same download id. - let permanent_asset_removal = if permanent_if_unfinished_requested { + let mut permanent_asset_removal = if permanent_if_unfinished_requested { let persisted = load_persisted_download_item( &app_handle.state::(), &id, )?; - !matches!(persisted.status, crate::ipc::DownloadStatus::Completed) + let daemon_completed = if let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) { + removal_payload_completed(state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, &gid).await? + } else { false }; + if daemon_completed { + let db = app_handle.state::(); + crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| { + row.insert("status".into(), serde_json::json!("completed")); + Ok(()) + })?; + } + !daemon_completed && !matches!(persisted.status, crate::ipc::DownloadStatus::Completed | crate::ipc::DownloadStatus::Seeding | crate::ipc::DownloadStatus::WaitingToSeed) } else { false }; + let removal_stop_started = Instant::now(); let active_kind = state.queue_manager.active_kind(&id).await; let registered_lifecycle_generation = state .queue_manager @@ -6238,6 +6292,17 @@ async fn remove_download( state.queue_manager.allow_aria2_retries(&id).await; return Err(error); } + if permanent_asset_removal && removal_payload_completed( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, gid).await? + { + let db = app_handle.state::(); + crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| { + row.insert("status".into(), serde_json::json!("completed")); + Ok(()) + })?; + permanent_asset_removal = false; + } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_torrent_telemetry(&id).await; @@ -6327,6 +6392,17 @@ async fn remove_download( state.queue_manager.allow_aria2_retries(&id).await; return Err(error); } + if permanent_asset_removal && removal_payload_completed( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, &late_gid).await? + { + let db = app_handle.state::(); + crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| { + row.insert("status".into(), serde_json::json!("completed")); + Ok(()) + })?; + permanent_asset_removal = false; + } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_torrent_telemetry(&id).await; @@ -6360,6 +6436,19 @@ async fn remove_download( } }; + if permanent_asset_removal && state.queue_manager + .media_payload_completed(&id, media_lifecycle_generation).await + { + let db = app_handle.state::(); + crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| { + row.insert("status".into(), serde_json::json!("completed")); + Ok(()) + })?; + permanent_asset_removal = false; + } + + log::info!("download removal [id={} stage=stop elapsed_ms={}]", id, removal_stop_started.elapsed().as_millis()); + let removal_cleanup_started = Instant::now(); let owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?; let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?; let torrent_removal_paths = @@ -6462,6 +6551,25 @@ async fn remove_download( } } + if durable_removal_job { + let mut roots = Vec::new(); + if should_delete_assets { + roots.extend(owned_paths.iter().cloned()); + roots.extend(primary_path.iter().cloned()); + roots.extend(unowned_replacement_target.iter().map(|target| target.target.clone())); + for path in roots.clone() { + for suffix in [".aria2", ".part", ".ytdl"] { + let mut sidecar = path.as_os_str().to_os_string(); + sidecar.push(suffix); + roots.push(sidecar.into()); + } + roots.extend(collect_media_processing_artifacts_for_permanent_removal(&path, &app_handle).await?); + } + } + roots.push(crate::torrent::managed_torrent_path(&app_handle, &id)?); + removal_jobs::fence_assets(&app_handle, &id, &roots)?; + } + let cleanup_result = async { if should_delete_assets { for path in &owned_paths { @@ -6492,16 +6600,19 @@ async fn remove_download( .await?; } } - if permanent_asset_removal { + if permanent_asset_removal || durable_removal_job { remove_managed_torrent_permanently(&app_handle, &id).await?; } else { crate::torrent::remove_managed_torrent(&app_handle, &id).await; } - crate::download_ownership::remove(&app_handle, &id)?; + if !removal_jobs::has_job(&app_handle, &id)? { + crate::download_ownership::remove(&app_handle, &id)?; + } Ok::<(), String>(()) } .await; drop(cleanup_target_guards); + log::info!("download removal [id={} stage=cleanup success={} elapsed_ms={}]", id, cleanup_result.is_ok(), removal_cleanup_started.elapsed().as_millis()); state.queue_manager.release_registered_id(&id).await; cleanup_result @@ -7500,6 +7611,7 @@ async fn detach_download_for_reconfigure( properties_window::ensure_main_window(&caller)?; log::info!("detach_download_for_reconfigure called for id: {}", id); let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; detach_download_for_reconfigure_locked( &app_handle, state.inner(), @@ -9611,6 +9723,7 @@ async fn enqueue_download_locked( mut item: queue::EnqueueItem, _control_guard: &queue::Aria2ControlGuard, ) -> Result { + removal_jobs::ensure_not_removing(app_handle, &item.id).map_err(AppError::Internal)?; if item.is_torrent.unwrap_or(false) { validate_torrent_enqueue(app_handle, &mut item) .await @@ -9799,7 +9912,9 @@ async fn enqueue_many( // serialized with pause/resume/remove for this download. The guard is // intentionally held through every early-continue path below. let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; - let validation = if item.is_torrent.unwrap_or(false) { + let validation = if let Err(error) = removal_jobs::ensure_not_removing(&app_handle, &id) { + Err(error) + } else if item.is_torrent.unwrap_or(false) { validate_torrent_enqueue(&app_handle, &mut item).await } else { match queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) { @@ -10090,6 +10205,7 @@ async fn remove_from_queue( ) -> Result { properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id).map_err(AppError::Internal)?; let removed = state.queue_manager.remove_from_pending(&id).await; if removed { let _ = crate::download_ownership::remove(&app_handle, &id); @@ -10430,6 +10546,7 @@ async fn set_torrent_file_selection( ) -> Result { properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; let item = load_persisted_torrent_item(database.inner(), &id)?; if item.is_torrent != Some(true) { return Err("file selection is available only for Torrent downloads".to_string()); @@ -11252,6 +11369,7 @@ async fn move_torrent_data( Some(session_id) }; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; if let Some(session_id) = properties_session_id.as_deref() { if !properties.session_matches(caller.label(), session_id)? { return Err("Properties window session is no longer current".to_string()); @@ -11788,6 +11906,7 @@ async fn verify_torrent_data( // replacement with pause/resume/remove so a paused GID cannot reject the // maintenance enqueue as a duplicate task or race it with a late event. let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&app_handle, &id)?; let item = load_persisted_torrent_item(database.inner(), &id)?; if item.is_torrent != Some(true) { return Err("integrity verification is available only for Torrent downloads".to_string()); @@ -12139,6 +12258,7 @@ async fn set_torrent_web_seeds( ) -> Result, String> { properties_window::ensure_main_window(&caller)?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + removal_jobs::ensure_not_removing(&state.queue_manager.app_handle(), &id)?; let active = state.queue_manager.is_registered(&id).await && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)); let normalized = if active { @@ -14116,6 +14236,17 @@ fn ack_extension_download( #[cfg(test)] mod tests { + #[test] + fn removal_recognizes_completed_seeders_without_confusing_downloaders() { + for status in [serde_json::json!({"status": "complete"}), + serde_json::json!({"status": "active", "seeder": "true"}), + serde_json::json!({"status": "paused", "seeder": true})] { + assert!(super::removal_status_has_completed_payload(&status)); + } + assert!(!super::removal_status_has_completed_payload(&serde_json::json!({"status": "active", "seeder": "false"}))); + assert!(!super::removal_status_has_completed_payload(&serde_json::json!({}))); + } + use super::{ aggregate_media_byte_progress, aggregate_media_fraction, append_ytdlp_config_option, append_ytdlp_http_headers, @@ -19485,8 +19616,12 @@ pub fn run() { "errorCode", "errorMessage", "verifiedLength", - "verifyIntegrityPending" + "verifyIntegrityPending", + "fileAllocationPending" ]]); + let allocation_mappings: HashMap<_, _> = poll_mgr.aria2_gid_mappings().into_iter() + .filter_map(|(gid, _)| poll_mgr.aria2_gid_mapping(&gid).map(|mapping| (gid, mapping))) + .collect(); let active_poll_started = Instant::now(); let active_list = match rpc_call( poll_port.load(std::sync::atomic::Ordering::Relaxed), @@ -19877,9 +20012,17 @@ pub fn run() { // notification was lost, this is sufficient // to end the allocation phase for the same // mapped lifecycle. - poll_mgr - .complete_aria2_allocation_for_gid(gid, completed) - .await; + if let Some(pending) = status_info.get("fileAllocationPending").and_then(|value| value.as_bool()) { + if let Some(observed) = allocation_mappings.get(gid) { + poll_mgr.observe_aria2_allocation(gid, observed, pending && !verify_pending && verified_length.is_none()).await; + } + } else if is_torrent { + if let Some(observed) = allocation_mappings.get(gid) { + poll_mgr.observe_aria2_allocation(gid, observed, false).await; + } + } else { + poll_mgr.complete_aria2_allocation_for_gid(gid, completed).await; + } if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) || !poll_mgr .is_aria2_control_epoch_current(&id, control_epoch) @@ -20393,6 +20536,10 @@ pub fn run() { ]); let download_queue_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![ remove_download, + removal_jobs::submit_download_removals, + removal_jobs::list_download_removals, + removal_jobs::resume_download_removals, + removal_jobs::retry_download_removal, get_download_primary_path, detach_download_for_reconfigure, enqueue_download, @@ -20522,6 +20669,10 @@ pub fn run() { | "set_torrent_overall_upload_limit" | "set_global_speed_limit" => torrent_storage_handler(invoke), "remove_download" + | "submit_download_removals" + | "list_download_removals" + | "resume_download_removals" + | "retry_download_removal" | "get_download_primary_path" | "detach_download_for_reconfigure" | "enqueue_download" diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 987205a..d82b0e7 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -39,7 +39,7 @@ fn windows_directory_identity(path: &Path) -> io::Result { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; @@ -105,7 +105,7 @@ pub fn file_identity(path: &Path) -> Option { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, std::ptr::null_mut(), ) }; diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index d08a027..6735a6d 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1317,6 +1317,7 @@ pub struct QueueManager { /// Current Aria2 lifecycles whose files are expected to be preallocated. /// The generation fences late start/clear events from an older GID. aria2_allocation_pending: Mutex>, + media_completed_generations: Mutex>, /// download id -> spawn payload for aria2 transient-error re-addUri retries. aria2_payloads: Mutex>, @@ -1426,6 +1427,7 @@ impl QueueManager { pending_completion: Arc::new(Mutex::new(HashMap::new())), pending_download_starts: Arc::new(Mutex::new(HashSet::new())), aria2_allocation_pending: Mutex::new(HashMap::new()), + media_completed_generations: Mutex::new(HashMap::new()), aria2_payloads: Mutex::new(HashMap::new()), aria2_connection_options: Mutex::new(HashMap::new()), aria2_dispatch_inflight: Mutex::new(HashMap::new()), @@ -1852,6 +1854,7 @@ impl QueueManager { || !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) { self.abandon_seed_start(&id); + if self.removal_requested(&id) { self.release_seed_tracking(&id); } return; } let control_epoch = self.current_aria2_control_epoch(&id).await; @@ -1875,10 +1878,12 @@ impl QueueManager { || !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) || self.aria2_gid_for_download(&id).as_deref() != Some(gid.as_str()) || !self.is_aria2_control_epoch_current(&id, control_epoch).await + || self.removal_requested(&id) { self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation) .await; self.abandon_seed_start(&id); + if self.removal_requested(&id) { self.release_seed_tracking(&id); } return; } let epoch = self.next_aria2_control_epoch(&id).await; @@ -2091,6 +2096,7 @@ impl QueueManager { /// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach). pub async fn release_registered_id(&self, id: &str) { + self.media_completed_generations.lock().await.remove(id); self.registered_ids.lock().await.remove(id); self.registered_lifecycle_generations.lock().await.remove(id); // A released lifecycle cannot be resumed by a delayed retry worker. @@ -2142,6 +2148,7 @@ impl QueueManager { } }; if released { + self.media_completed_generations.lock().await.remove(id); self.aria2_retry_cancelled.lock().await.remove(id); self.release_seed_tracking(id); self.notify.notify_waiters(); @@ -4270,6 +4277,27 @@ impl QueueManager { } } + /// Apply explicit engine telemetry only to the observed native lifecycle. + pub async fn observe_aria2_allocation(&self, gid: &str, mapping: &Aria2GidMapping, pending: bool) { + let _guard = self.acquire_aria2_control(&mapping.id).await; + if !self.is_current_aria2_gid_mapping(gid, mapping) + || !self.is_aria2_control_epoch_current(&mapping.id, mapping.epoch).await { + return; + } + let Some(generation) = self.registered_lifecycle_generation(&mapping.id).await else { return; }; + let changed = { + let mut entries = self.aria2_allocation_pending.lock().await; + if pending { + entries.insert(mapping.id.clone(), (mapping.epoch, generation)) != Some((mapping.epoch, generation)) + } else { + entries.remove(&mapping.id).is_some() + } + }; + if changed { + self.emit_allocation_event(&mapping.id, pending, generation); + } + } + pub async fn complete_aria2_allocation_for_gid(&self, gid: &str, downloaded_bytes: u64) { // Aria2 reports an active GID with completedLength=0 while it is still // creating preallocated files. That observation is not native @@ -4291,6 +4319,7 @@ impl QueueManager { }; mapping }; + if self.aria2_is_torrent(&mapping.id).await { return; } if !self .is_current_aria2_gid_mapping(gid, &mapping) || !self @@ -4324,6 +4353,7 @@ impl QueueManager { }; mapping }; + if self.aria2_is_torrent(&mapping.id).await { return; } if !self .is_current_aria2_gid_mapping(gid, &mapping) || !self @@ -4381,6 +4411,12 @@ impl QueueManager { /// The long-running dispatcher. One instance is spawned in setup(). /// It scans for a queue with capacity before reserving the global slot, so /// a saturated front queue cannot block later eligible queues. + fn removal_requested(&self, id: &str) -> bool { + let Some(db) = self.app_handle.try_state::() else { return false; }; + let Ok(connection) = db.lock() else { return true; }; + connection.query_row("SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", [id], |row| row.get::<_, bool>(0)).unwrap_or(true) + } + pub async fn run_dispatcher(self: Arc) { loop { let notified = self.notify.notified(); @@ -4417,7 +4453,7 @@ impl QueueManager { // permit and active kind under the same guard prevents that command // from observing a half-started lifecycle. let control_guard = self.acquire_aria2_control(&id).await; - if !self + if self.removal_requested(&id) || !self .is_registered_generation(&id, lifecycle_generation) .await { @@ -4645,6 +4681,21 @@ impl QueueManager { } } + pub(crate) async fn media_payload_completed(&self, id: &str, generation: u64) -> bool { + self.media_completed_generations.lock().await.get(id).copied() == Some(generation) + } + + fn persist_completed_removal_payload(&self, id: &str) -> Result<(), String> { + if !self.removal_requested(id) { return Ok(()); } + if let Some(db) = self.app_handle.try_state::() { + crate::db::mutate_download(&mut *db.lock()?, id, db.is_portable(), |row| { + row.insert("status".into(), serde_json::json!("completed")); + Ok(()) + })?; + } + Ok(()) + } + /// Terminal handler for non-aria2 transfers. Emits state and frees the permit. /// Intentional cancellation is silent, but still releases backend ownership. /// Note: `id` is the frontend download UUID, which survives indefinitely as @@ -4671,6 +4722,10 @@ impl QueueManager { match outcome { Ok(()) => { + if self.persist_completed_removal_payload(id).is_err() { + log::error!("could not persist media completion during removal [id={}]", id); + return; + } self.emit_state(id, DownloadStatus::Completed); self.release_registered_id_for_generation(id, lifecycle_generation) .await; @@ -4861,6 +4916,13 @@ impl QueueManager { }) .unwrap_or((None, false, false)) }; + if matches!(outcome, PendingOutcome::Complete | PendingOutcome::Seeding) + && !(verification_only && !verification_observed) + && self.persist_completed_removal_payload(id).is_err() + { + log::error!("could not persist completion during removal [id={}]", id); + return; + } let outcome = match outcome { PendingOutcome::Complete if verification_only && !verification_observed => { PendingOutcome::Error( @@ -9010,6 +9072,13 @@ impl SidecarSpawner for ProductionSpawner { .await }; if let Ok(path) = outcome.as_ref() { + // Publish completion evidence before Finished acknowledges a racing + // removal. The control lock may be held by that waiting remover. + state.queue_manager.media_completed_generations.lock().await + .insert(id.to_string(), lifecycle_generation); + if state.queue_manager.persist_completed_removal_payload(id).is_err() { + log::error!("could not persist media completion before removal acknowledgement [id={}]", id); + } let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, path); if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { use tauri::Emitter; @@ -9323,6 +9392,39 @@ mod tests { )); } + #[tokio::test] + async fn media_removal_completion_evidence_is_lifecycle_fenced() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())).unwrap(); + let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); + manager.reserve_enqueue_generation("media-removal", 7).await.unwrap(); + manager.media_completed_generations.lock().await.insert("media-removal".into(), 7); + assert!(manager.media_payload_completed("media-removal", 7).await); + assert!(!manager.media_payload_completed("media-removal", 8).await); + manager.release_registered_id_for_generation("media-removal", 6).await; + assert!(manager.media_payload_completed("media-removal", 7).await); + manager.release_registered_id_for_generation("media-removal", 7).await; + assert!(!manager.media_payload_completed("media-removal", 7).await); + } + + #[tokio::test] + async fn explicit_allocation_telemetry_ends_without_payload_and_rejects_old_epoch() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())).unwrap(); + let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); + manager.reserve_enqueue_generation("telemetry", 7).await.unwrap(); + manager.next_aria2_control_epoch("telemetry").await; + manager.remember_gid("telemetry".into(), "telemetry-gid".into()).await; + let observed = manager.aria2_gid_mapping("telemetry-gid").unwrap(); + manager.observe_aria2_allocation("telemetry-gid", &observed, true).await; + assert_eq!(allocation_pending_epoch(&manager, "telemetry"), Some((observed.epoch, 7))); + manager.observe_aria2_allocation("telemetry-gid", &observed, false).await; + assert_eq!(allocation_pending_epoch(&manager, "telemetry"), None); + manager.next_aria2_control_epoch("telemetry").await; + manager.observe_aria2_allocation("telemetry-gid", &observed, true).await; + assert_eq!(allocation_pending_epoch(&manager, "telemetry"), None); + } + #[tokio::test] async fn allocation_stays_pending_while_async_add_uri_is_in_flight() { let app = tauri::test::mock_builder() diff --git a/src-tauri/src/removal_jobs.rs b/src-tauri/src/removal_jobs.rs new file mode 100644 index 0000000..0363f2b --- /dev/null +++ b/src-tauri/src/removal_jobs.rs @@ -0,0 +1,398 @@ +//! Durable removal intent is independent of renderer download snapshots. Completed +//! jobs remain as tombstones, so an old save can never recreate a deleted UUID. +use crate::ipc::{DownloadAssetRemovalPolicy, DownloadRemovalJob, DownloadRemovalPhase as Phase}; +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::{Emitter, Manager}; + +static WORKER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn jobs(connection: &Connection) -> Result, String> { + let mut statement = connection + .prepare("SELECT data FROM download_removal_jobs ORDER BY rowid") + .map_err(|e| e.to_string())?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.map(|row| { + serde_json::from_str(&row.map_err(|e| e.to_string())?).map_err(|e| e.to_string()) + }) + .collect() +} + +fn save(connection: &Connection, job: &DownloadRemovalJob) -> Result<(), String> { + connection.execute("INSERT INTO download_removal_jobs(id,data) VALUES(?1,?2) ON CONFLICT(id) DO UPDATE SET data=excluded.data", + params![job.id, serde_json::to_string(job).map_err(|e| e.to_string())?]).map_err(|e| e.to_string())?; + Ok(()) +} + +pub(crate) fn has_job(app: &tauri::AppHandle, id: &str) -> Result { + let db = app.state::(); + let connection = db.lock()?; + let exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", + [id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + Ok(exists) +} + +pub(crate) fn ensure_not_removing(app: &tauri::AppHandle, id: &str) -> Result<(), String> { + if has_job(app, id)? { + Err("Download removal is pending or requires retry".into()) + } else { + Ok(()) + } +} + +#[tauri::command] +pub fn list_download_removals( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result, String> { + crate::properties_window::ensure_main_window(&caller)?; + jobs(&*app.state::().lock()?) +} + +#[tauri::command] +pub async fn submit_download_removals( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + ids: Vec, + delete_assets: bool, +) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; + // Fence each admission before recording intent, retaining all existing rows + // and ownership records until physical cleanup has actually succeeded. + let result = async { + let state = app.state::(); + for id in ids { + let _guard = state.queue_manager.acquire_aria2_control(&id).await; + let job = { + let db = app.state::(); + let connection = db.lock()?; + let existing: Option = connection + .query_row( + "SELECT data FROM download_removal_jobs WHERE id=?1", + [&id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if existing.is_some() { + continue; + } + let exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM downloads WHERE id=?1)", + [&id], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + if !exists { + return Err("Download is not durably saved".into()); + } + let job = DownloadRemovalJob { + id: id.clone(), + revision: 1, + delete_assets, + phase: Phase::Pending, + error: None, + }; + save(&connection, &job)?; + job + }; + state.queue_manager.remove_from_pending(&id).await; + state.queue_manager.cancel_aria2_retries(&id).await; + if state.queue_manager.is_waiting_to_seed(&id) { + state.queue_manager.release_seed_tracking(&id); + } + let _ = app.emit("download-removal", &job); + } + Ok(()) + } + .await; + kick(&app); + result +} + +#[tauri::command] +pub fn resume_download_removals( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; + kick(&app); + Ok(()) +} + +#[tauri::command] +pub fn retry_download_removal( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + id: String, +) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; + { + let db = app.state::(); + let connection = db.lock()?; + let mut job = jobs(&connection)? + .into_iter() + .find(|job| job.id == id) + .ok_or("Removal job not found")?; + if job.phase != Phase::Failed { + return Ok(()); + } + job.revision = job.revision.saturating_add(1); + job.phase = Phase::Pending; + job.error = None; + save(&connection, &job)?; + let _ = app.emit("download-removal", &job); + } + kick(&app); + Ok(()) +} + +fn kick(app: &tauri::AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _worker = WORKER.lock().await; + // Filesystem guards include synchronous platform APIs. Run the entire + // cleanup on a blocking thread, with async RPC/timers using the runtime. + let runtime = tokio::runtime::Handle::current(); + let result = tauri::async_runtime::spawn_blocking(move || runtime.block_on(run(app))).await; + if !matches!(result, Ok(Ok(()))) { + log::error!("download removal worker stopped; durable jobs retained for recovery"); + } + }); +} + +async fn run(app: tauri::AppHandle) -> Result<(), String> { + loop { + let next = { + let db = app.state::(); + let connection = db.lock()?; + jobs(&connection)? + .into_iter() + .find(|job| matches!(job.phase, Phase::Pending | Phase::Running)) + }; + let Some(mut job) = next else { + return Ok(()); + }; + job.revision = job.revision.saturating_add(1); + job.phase = Phase::Running; + let saved = app + .state::() + .lock() + .and_then(|connection| save(&connection, &job)); + if let Err(error) = saved { + emit_persistence_failure(&app, &mut job); + return Err(error); + } + let _ = app.emit("download-removal", &job); + let started = std::time::Instant::now(); + let result = crate::remove_download_inner( + app.clone(), + app.state::(), + job.id.clone(), + job.delete_assets, + Some(false), + Some(DownloadAssetRemovalPolicy::PermanentIfUnfinished), + None, + true, + ) + .await; + job.revision = job.revision.saturating_add(1); + let committed = (|| -> Result<(), String> { + let db = app.state::(); + let mut connection = db.lock()?; + let tx = connection.transaction().map_err(|e| e.to_string())?; + if result.is_ok() { + tx.execute("DELETE FROM download_ownership WHERE id=?1", [&job.id]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM download_owned_paths WHERE id=?1", [&job.id]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM download_removal_paths WHERE id=?1", [&job.id]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM download_removal_assets WHERE id=?1", [&job.id]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM downloads WHERE id=?1", [&job.id]) + .map_err(|e| e.to_string())?; + job.phase = Phase::Completed; + job.error = None; + } else { + job.phase = Phase::Failed; + // Native errors can contain private paths. Keep only actionable, + // safe UI guidance in the durable record and public event. + job.error = Some("Removal could not finish. Close programs using the files, check drive access and permissions, then retry removal.".into()); + } + save(&tx, &job)?; + tx.commit().map_err(|e| e.to_string()) + })(); + if let Err(error) = committed { + emit_persistence_failure(&app, &mut job); + return Err(error); + } + log::info!( + "download removal [id={} phase={:?} elapsed_ms={}]", + job.id, + job.phase, + started.elapsed().as_millis() + ); + let _ = app.emit("download-removal", &job); + } +} + +fn emit_persistence_failure(app: &tauri::AppHandle, job: &mut DownloadRemovalJob) { + job.phase = Phase::Failed; + job.error = Some( + "Removal could not be saved. Check disk space and drive access, then retry removal.".into(), + ); + let _ = app.emit("download-removal", &*job); +} + +// Kept in a private table, never in shared IPC job data: paths and filesystem +// identities are authorization evidence, not diagnostic or presentation data. +type AssetManifest = std::collections::BTreeMap; + +fn snapshot_assets(roots: &[std::path::PathBuf]) -> Result { + let mut pending = roots.to_vec(); + let mut manifest = AssetManifest::new(); + while let Some(path) = pending.pop() { + if manifest.contains_key(&path) { + continue; + } + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("Could not inspect removal assets".into()), + }; + if crate::metadata_is_link_or_reparse(&metadata) || crate::path_has_symlink_component(&path) + { + return Err("Removal asset contains a symbolic link or reparse point".into()); + } + let identity = crate::target_identity(&path, &metadata); + if identity.starts_with("windows-path:") || identity == "portable" { + return Err("Could not establish removal asset identity".into()); + } + let signature = if metadata.is_dir() { + // Directory mtime changes as its children are removed; identity and + // birth time remain stable across partial cleanup and restart. + pending.extend( + std::fs::read_dir(&path) + .map_err(|_| "Could not inspect removal directory")? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + .map_err(|_| "Could not inspect removal entry")?, + ); + format!("dir:{identity}:{:?}", metadata.created().ok()) + } else if metadata.is_file() { + format!( + "file:{identity}:{:?}:{}:{}", + metadata.created().ok(), + metadata.len(), + crate::target_modified(&metadata) + ) + } else { + return Err("Removal asset is not a regular file or directory".into()); + }; + manifest.insert(path, signature); + } + Ok(manifest) +} + +fn validate_manifest(expected: &AssetManifest, current: &AssetManifest) -> Result<(), String> { + // Missing entries are expected after interrupted cleanup. Newly created or + // replaced entries never inherit authorization from the old path owner. + if current + .iter() + .any(|(path, signature)| expected.get(path) != Some(signature)) + { + return Err("Removal assets changed since cleanup began".into()); + } + Ok(()) +} + +pub(crate) fn fence_assets( + app: &tauri::AppHandle, + id: &str, + roots: &[std::path::PathBuf], +) -> Result<(), String> { + let current = snapshot_assets(roots)?; + let db = app.state::(); + let connection = db.lock()?; + let previous: Option = connection + .query_row( + "SELECT data FROM download_removal_assets WHERE id=?1", + [id], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some(previous) = previous { + validate_manifest( + &serde_json::from_str(&previous).map_err(|_| "Invalid removal asset manifest")?, + ¤t, + ) + } else { + connection + .execute( + "INSERT INTO download_removal_assets(id,data) VALUES(?1,?2)", + params![ + id, + serde_json::to_string(¤t).map_err(|e| e.to_string())? + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn interrupted_cleanup_rejects_replacement_and_new_files() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let a = root.join("a"); + let b = root.join("b"); + std::fs::write(&a, b"original").unwrap(); + std::fs::write(&b, b"original").unwrap(); + let roots = vec![root.clone()]; + let manifest = snapshot_assets(&roots).unwrap(); + std::fs::remove_file(&a).unwrap(); + assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok()); + let replacement = root.join("replacement"); + std::fs::write(&replacement, b"original").unwrap(); + std::fs::rename(&replacement, &a).unwrap(); + assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err()); + std::fs::remove_file(&a).unwrap(); + std::fs::write(directory.path().join("new"), b"unrelated").unwrap(); + assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err()); + } + #[cfg(unix)] + #[test] + fn retry_allows_permission_repair_but_rejects_content_changes() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().canonicalize().unwrap().join("file"); + std::fs::write(&file, b"original").unwrap(); + let roots = vec![file.clone()]; + let manifest = snapshot_assets(&roots).unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok()); + std::fs::write(&file, b"changed content").unwrap(); + assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err()); + } + + #[cfg(unix)] + #[test] + fn removal_manifest_does_not_follow_links() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + std::os::unix::fs::symlink(&root, root.join("link")).unwrap(); + assert!(snapshot_assets(&[root]).is_err()); + } +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 5e24c45..be38935 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -19,6 +19,15 @@ pub enum StorageMode { impl StorageMode { pub fn detect() -> Self { + // Packaged smoke runs must never migrate or mutate the installed app's + // database. The harness creates an isolated root before launching us. + if std::env::var("FIRELINK_SMOKE_TEST").as_deref() == Ok("1") { + if let Some(root) = std::env::var_os("FIRELINK_SMOKE_STORAGE_ROOT").filter(|root| !root.is_empty()) { + let root = PathBuf::from(root); + assert!(root.is_absolute() && root.is_dir(), "invalid smoke storage root"); + return Self::Portable { root }; + } + } let Some(executable) = std::env::current_exe().ok() else { return Self::Standard; }; diff --git a/src/bindings/DownloadRemovalJob.ts b/src/bindings/DownloadRemovalJob.ts new file mode 100644 index 0000000..b6e8a33 --- /dev/null +++ b/src/bindings/DownloadRemovalJob.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DownloadRemovalPhase } from "./DownloadRemovalPhase"; + +export type DownloadRemovalJob = { id: string, revision: number, deleteAssets: boolean, phase: DownloadRemovalPhase, error: string | null, }; diff --git a/src/bindings/DownloadRemovalPhase.ts b/src/bindings/DownloadRemovalPhase.ts new file mode 100644 index 0000000..64b8402 --- /dev/null +++ b/src/bindings/DownloadRemovalPhase.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DownloadRemovalPhase = "pending" | "running" | "failed" | "completed"; diff --git a/src/components/DeleteConfirmationModal.tsx b/src/components/DeleteConfirmationModal.tsx index a93d894..01160d1 100644 --- a/src/components/DeleteConfirmationModal.tsx +++ b/src/components/DeleteConfirmationModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useDownloadStore } from '../store/useDownloadStore'; import { AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -6,20 +6,11 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; export const DeleteConfirmationModal: React.FC = () => { const { t } = useTranslation(); - const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore(); - const [errorMessage, setErrorMessage] = useState(''); - const [isRemoving, setIsRemoving] = useState(false); + const { deleteModalState, closeDeleteModal, requestRemovals, downloads } = useDownloadStore(); const modalRef = useModalFocus(deleteModalState.isOpen); useEffect(() => { - if (deleteModalState.isOpen) { - setIsRemoving(false); - setErrorMessage(''); - } - }, [deleteModalState.isOpen]); - - useEffect(() => { - if (!deleteModalState.isOpen || isRemoving) return; + if (!deleteModalState.isOpen) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isTopmostModal(modalRef.current)) { event.preventDefault(); @@ -28,7 +19,7 @@ export const DeleteConfirmationModal: React.FC = () => { }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); - }, [closeDeleteModal, deleteModalState.isOpen, isRemoving]); + }, [closeDeleteModal, deleteModalState.isOpen]); if (!deleteModalState.isOpen) return null; @@ -43,35 +34,7 @@ export const DeleteConfirmationModal: React.FC = () => { return; } - setIsRemoving(true); - setErrorMessage(''); - let succeeded = 0; - const failures: string[] = []; - for (const id of ids) { - try { - await removeDownload( - id, - deleteFile, - false, - deleteFile ? 'permanentIfUnfinished' : undefined - ); - succeeded += 1; - } catch (error) { - failures.push(String(error)); - } - } - - if (failures.length > 0) { - setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, { - succeeded, - failed: failures.length, - detail: failures[0], - })); - setIsRemoving(false); - return; - } - setIsRemoving(false); - closeDeleteModal(); + await requestRemovals(ids, deleteFile); }; const handleRemoveFromList = () => removeMany(false); @@ -87,7 +50,7 @@ export const DeleteConfirmationModal: React.FC = () => {
{ - if (event.target === event.currentTarget && !isRemoving) handleCancel(); + if (event.target === event.currentTarget) handleCancel(); }} role="dialog" aria-modal="true" @@ -116,27 +79,23 @@ export const DeleteConfirmationModal: React.FC = () => { {t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
)} - {errorMessage &&
{errorMessage}
}
} + + ) : allocationVisible ? ( <>