diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 1a0a0471c6..a38aa8c62e 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -269,6 +269,21 @@ android { testOptions { unitTests.isReturnDefaultValues = true + // Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android") + // find the desktop-host build of our Arti JNI shim. The Android .so + // variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded + // on-device — this Linux x86_64 .so is just for JVM unit-test runs. + // -Pamethyst.arti.integration=true opts the (slow, network-dependent) + // tests in; see TorArtiNativeIntegrationTest.kdoc. + unitTests.all { test -> + test.systemProperty( + "java.library.path", + "${projectDir}/src/test/native-libs/x86_64-linux", + ) + project + .findProperty("amethyst.arti.integration") + ?.let { test.systemProperty("amethyst.arti.integration", it.toString()) } + } } } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt new file mode 100644 index 0000000000..fcbc90080c --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.tor + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.vitorpamplona.amethyst.ui.tor.TorService +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +/** + * Real-Arti bootstrap + SOCKS round trip on-device. Verifies that the self-heal / + * destroy / re-init paths work end-to-end against the actual native lib. + * + * **This test is [Ignore]'d by default** because: + * - It needs network egress to the Tor network from the device/emulator. Many CI + * environments don't have it. + * - Bootstrap on a cold device can take 30-120s; the test costs real wall-clock time. + * - It depends on `check.torproject.org` being reachable. + * + * **To run manually:** + * 1. Connect a device or start an emulator that has internet egress to Tor. + * 2. Remove the `@Ignore` annotation below. + * 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest` + * + * **What it covers that [TorManagerTest] does not:** + * - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind. + * - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump). + * - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds. + * - OkHttp routing traffic through the SOCKS port and Arti exiting through the + * Tor network. + * + * **Companion fast tests:** `amethyst/src/test/.../tor/TorManagerTest.kt` covers the + * Kotlin-side self-heal logic (watchdog, cooldown, network change, status routing) + * with virtual time and in-memory fakes — no Arti required. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.") +class TorBootstrapInstrumentedTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val torService = TorService(context) + + @After + fun tearDown() = + runBlocking { + // Drop the native client so this test's state file lock doesn't bleed into + // the next instrumented run on the same device. + torService.reset() + } + + /** + * Cold-start bootstrap. The whole point of the custom Arti build is that this + * works at all — if create_bootstrapped panics (e.g., because we forgot to install + * a rustls CryptoProvider after an arti bump) the test catches it. + */ + @Test + fun `bootstraps to Active within 120s`() = + runBlocking(Dispatchers.IO) { + val elapsed = + measureTimeMillis { + torService.start() + val active = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + assertTrue("SOCKS port should be > 0", active.port > 0) + } + // Logged via assertEquals failure-on-too-slow; an actual `Log.i` would be invisible. + // Bootstrap should comfortably fit in 120s on a healthy network. + assertTrue("Bootstrap took ${elapsed}ms, expected < ${BOOTSTRAP_TIMEOUT_MS}ms", elapsed < BOOTSTRAP_TIMEOUT_MS) + } + + /** + * SOCKS round-trip through Tor. Hits `check.torproject.org` which returns a JSON + * payload including `"IsTor":true` when the request actually exited via Tor. + * Catches regressions where the listener binds but no traffic flows (e.g., a + * broken handler-spawn race, or a crypto provider mismatch on the TLS handshake). + */ + @Test + fun `proxies HTTPS through Tor and reports IsTor true`() = + runBlocking(Dispatchers.IO) { + torService.start() + val active = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + + val client = + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", active.port))) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val request = + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build() + + val body = + client.newCall(request).execute().use { resp -> + assertEquals("HTTP 200", 200, resp.code) + resp.body.string() + } + assertTrue( + "Response should report IsTor:true — actual body: $body", + body.contains("\"IsTor\":true"), + ) + } + + /** + * Verifies the destroy → re-init cycle that backs the self-heal path. After + * [TorService.reset], the next [TorService.start] must rebuild the TorClient and + * bring SOCKS back to Active — without a "state file already locked" error from + * the still-alive previous client. + */ + @Test + fun `reset then re-start brings SOCKS back to Active`() = + runBlocking(Dispatchers.IO) { + torService.start() + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } + + torService.reset() + assertEquals(TorServiceStatus.Off, torService.status.value) + + torService.start() + val second = + withTimeout(BOOTSTRAP_TIMEOUT_MS) { + torService.status.first { it is TorServiceStatus.Active } + } as TorServiceStatus.Active + assertTrue("Second bootstrap port valid", second.port > 0) + } + + companion object { + private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 976bec0c14..233155b80c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.amethyst.ui.tor.TorService import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -190,12 +191,12 @@ class AppModules( UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } - val torManager = TorManager(torPrefs, appContext, applicationIOScope) + val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope) - // Whenever the underlying network identity changes (wifi↔cellular, regained from - // offline, etc.) we clear any active Tor session bypass so the manager re-attempts - // bootstrap on the new network. The remembered-approval window is unaffected: if Tor - // stays stuck we will silently bypass again after the timeout fires. + // Network identity change (wifi↔cellular, regained from offline, captive portal + // cleared) — the old network's guards/circuits are dead, and Arti's in-memory + // client + on-disk state/ both need a fresh start. onNetworkChange drops the + // TorClient, clears the bypass + persisted approval, and triggers a full re-init. init { applicationIOScope.launch { connManager.status @@ -203,7 +204,7 @@ class AppModules( .filterNotNull() .distinctUntilChanged() .drop(1) - .collect { torManager.clearSessionBypass() } + .collect { torManager.onNetworkChange() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index daebe61dc4..900cc3e78f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -29,12 +29,14 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -48,10 +50,13 @@ class TorSharedPreferences( prefs: TorSettings, val context: Context, val scope: CoroutineScope, -) { +) : TorPreferencesPort { // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs val value = TorSettingsFlow.build(prefs) + override val torType: StateFlow get() = value.torType + override val externalSocksPort: StateFlow get() = value.externalSocksPort + @OptIn(FlowPreview::class) val saving = value.propertyWatchFlow @@ -66,9 +71,9 @@ class TorSharedPreferences( value.toSettings(), ) - suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) + override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) - suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) + override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) companion object { // loads faster when individualized diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt index 6d066d1d08..9a1551f77e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt @@ -62,6 +62,15 @@ object ArtiNative { * @return 0 on success. */ external fun stopSocksProxy(): Int + + /** + * Drop the in-process TorClient so the next [initialize] call rebuilds + * it from scratch (fresh bootstrap, new guards/circuits). Aborts the + * SOCKS listener and all in-flight connection handlers so the state + * file lock can be released. + * @return 0 on success. + */ + external fun destroy(): Int } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt new file mode 100644 index 0000000000..3ccf01df15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import kotlinx.coroutines.flow.StateFlow + +/** + * The slice of [TorService] that [TorManager] drives. Extracted so the manager + * can be unit-tested without booting Arti via JNI — production wires + * `TorService(context)`, tests wire an in-memory fake. + */ +interface TorBackend { + val status: StateFlow + + suspend fun start() + + suspend fun stop() + + suspend fun reset() + + suspend fun resetWithCleanState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index ade8fa2213..b0d750f83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -20,10 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.tor -import android.content.Context import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,20 +40,26 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** * There should be only one instance of the Tor binding per app. * * Tor will connect as soon as status is listened to. + * + * [service] and [torPrefs] are constructor-injected so the manager can be unit-tested + * with in-memory fakes — see `TorManagerTest`. [ioDispatcher] is the dispatcher for + * background I/O (DataStore reads/writes, [TorBackend] calls); tests pass a + * `TestDispatcher` so virtual time controls scheduling. */ class TorManager( - private val torPrefs: TorSharedPreferences, - app: Context, + private val torPrefs: TorPreferencesPort, + val service: TorBackend, private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val nowMs: () -> Long = System::currentTimeMillis, ) { - val service = TorService(app) - /** * In-memory only — when true, the manager emits [TorServiceStatus.Off] regardless of * the persisted [TorType]. Cleared on process death, on network change, and on any @@ -69,26 +74,56 @@ class TorManager( */ @Volatile private var lastBypassApprovalMs: Long = 0L + /** + * Bumped by self-heal paths ([onNetworkChange], stuck-Connecting watcher) so the + * [status] combine re-fires and re-enters the [TorType.INTERNAL] branch — which + * calls [TorService.start] again and, because [TorService.reset] flipped + * `initialized` back to false, runs full Arti re-initialization with a fresh + * bootstrap, new guards, new circuits. + */ + private val resetEpoch = MutableStateFlow(0) + + /** Wall-clock of the last automatic self-heal — rate-limits the stuck-Connecting reset. */ + @Volatile private var lastSelfHealAtMs: Long = 0L + + /** + * Flipped the first time [status] reaches [TorServiceStatus.Active] in this process. Before + * that, the stuck-Connecting watchdog uses the gentler [TorService.reset] (drop client only) + * rather than [TorService.resetWithCleanState] — because on a slow legitimate first + * bootstrap there is no stale state to wipe, and wiping just forces an unnecessary + * re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game + * for the recovery to wipe. + */ + @Volatile private var hasEverBootstrapped: Boolean = false + init { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs() } - // Any user-initiated change to torType clears the in-memory bypass so the - // explicit user action wins over the implicit override. - torPrefs.value.torType + // Any user-initiated change to torType clears the in-memory bypass AND the + // remembered-approval window. Otherwise a single past "Use regular connection" + // traps the user in a silent-bypass loop: every Connecting span >60s + // auto-flips sessionBypass without showing the dialog, force-stop preserves + // the DataStore-backed approval, and toggling Tor off/on only clears the + // in-memory half — so wiping app data becomes the only recovery path. + torPrefs.torType .drop(1) - .onEach { sessionBypass.value = false } - .launchIn(scope) + .onEach { + sessionBypass.value = false + lastBypassApprovalMs = 0L + torPrefs.saveLastBypassApprovalMs(0L) + }.launchIn(scope) } @OptIn(ExperimentalCoroutinesApi::class) val status = combine( - torPrefs.value.torType, - torPrefs.value.externalSocksPort, + torPrefs.torType, + torPrefs.externalSocksPort, sessionBypass, - ) { torType, externalSocksPort, bypass -> + resetEpoch, + ) { torType, externalSocksPort, bypass, _ -> Triple(torType, externalSocksPort, bypass) }.transformLatest { (torType, externalSocksPort, bypass) -> if (bypass) { @@ -119,7 +154,7 @@ class TorManager( }.catch { e -> Log.e("TorManager") { "Tor service error: ${e.message}" } emit(TorServiceStatus.Off) - }.flowOn(Dispatchers.IO) + }.flowOn(ioDispatcher) .stateIn( scope, SharingStarted.WhileSubscribed(30000), @@ -164,28 +199,92 @@ class TorManager( false, ) + /** + * Fires once after [SELF_HEAL_AFTER_MS] of continuous [TorServiceStatus.Connecting]. + * Drives the watchdog wired up below. `transformLatest` cancels the pending delay + * whenever the status changes, so a brief Connecting blip never fires. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val selfHealSignal = + status.transformLatest { s -> + if (s is TorServiceStatus.Connecting) { + delay(SELF_HEAL_AFTER_MS) + emit(Unit) + } + } + + init { + // Self-heal watchdog. When status sits at Connecting for longer than + // SELF_HEAL_AFTER_MS, the in-memory Arti state is likely stuck — bad guards, + // broken circuits, expired consensus. Drop the TorClient and bump resetEpoch + // so the status combine re-fires and re-enters the INTERNAL branch, which + // runs full Arti re-init. Rate-limited so a permanently broken network + // doesn't loop us. Fires BEFORE the 60s connectionFailure dialog so most + // users never see it. + // + // Pre-first-bootstrap: gentle reset (drop client, keep state). On a slow + // legitimate first bootstrap there's nothing on disk worth wiping, and + // wiping just costs another full bootstrap cycle. + // Post-first-bootstrap: full reset (drop client + wipe state). Once we've + // seen Tor work once, a stuck Connecting almost certainly means stale on-disk + // state from a different network needs to go. + status + .onEach { + if (it is TorServiceStatus.Active) hasEverBootstrapped = true + }.launchIn(scope) + + selfHealSignal + .onEach { + val now = nowMs() + if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach + lastSelfHealAtMs = now + if (hasEverBootstrapped) { + Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms — self-healing (drop client + wipe state)" } + service.resetWithCleanState() + } else { + Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms on first bootstrap — self-healing (drop client only)" } + service.reset() + } + resetEpoch.update { it + 1 } + }.launchIn(scope) + } + fun rememberedApprovalActive(): Boolean { val ts = lastBypassApprovalMs - return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS + return ts > 0 && (nowMs() - ts) < APPROVAL_REMEMBER_MS } /** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */ fun approveBypassForOneHour() { - val now = System.currentTimeMillis() + val now = nowMs() lastBypassApprovalMs = now sessionBypass.value = true - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { torPrefs.saveLastBypassApprovalMs(now) } } /** - * Re-attempt Tor on this session — used on network change. Does not clear the - * remembered-approval window: if Tor stays stuck, we will silently bypass again - * after the timeout fires. + * Network identity changed (wifi↔cellular, captive portal cleared, regained from + * offline). The old network's guards and circuits are dead, but Arti's in-memory + * TorClient doesn't always notice — and even if it does, on-disk `state/` can hold + * unreachable guards that the next process load will pick up again. Drop the + * client, clear `sessionBypass`, clear the persisted approval, and bump + * [resetEpoch] so the status flow re-enters the INTERNAL branch with + * `initialized=false` — forcing a full Arti re-init with fresh bootstrap. */ - fun clearSessionBypass() { + fun onNetworkChange() { sessionBypass.value = false + lastBypassApprovalMs = 0L + // Prevent the stuck-Connecting watchdog from firing a second reset while the + // network-change bootstrap is still legitimately in progress (initial bootstrap + // on a new network can take ~10–30s, sometimes longer). + lastSelfHealAtMs = nowMs() + scope.launch(ioDispatcher) { + torPrefs.saveLastBypassApprovalMs(0L) + service.reset() + resetEpoch.update { it + 1 } + } } fun isSocksReady() = status.value is TorServiceStatus.Active @@ -195,5 +294,9 @@ class TorManager( companion object { const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L + + /** Self-heal kicks in BEFORE the 60s [BOOTSTRAP_TIMEOUT_MS] dialog so most users never see it. */ + const val SELF_HEAL_AFTER_MS: Long = 45_000L + const val SELF_HEAL_COOLDOWN_MS: Long = 5L * 60L * 1000L } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt new file mode 100644 index 0000000000..3188f15f81 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.flow.StateFlow + +/** + * The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the + * manager can be unit-tested without an Android `Context` (and without DataStore). + * Production wires `TorSharedPreferences`; tests wire an in-memory fake. + */ +interface TorPreferencesPort { + val torType: StateFlow + val externalSocksPort: StateFlow + + suspend fun loadLastBypassApprovalMs(): Long + + suspend fun saveLastBypassApprovalMs(value: Long) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index b74f567535..6b27c59acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -46,13 +46,13 @@ private const val MAX_PORT_RETRIES = 10 */ class TorService( val context: Context, -) { +) : TorBackend { private var socksPort = DEFAULT_SOCKS_PORT private val initialized = AtomicBoolean(false) private val proxyRunning = AtomicBoolean(false) private val _status = MutableStateFlow(TorServiceStatus.Off) - val status: StateFlow = _status.asStateFlow() + override val status: StateFlow = _status.asStateFlow() private fun artiDataDir() = File(context.filesDir, "arti") @@ -86,7 +86,7 @@ class TorService( * Initialize the TorClient (once) and start the SOCKS proxy. * Must be called from a coroutine on [Dispatchers.IO]. */ - suspend fun start() { + override suspend fun start() { if (proxyRunning.get()) { if (_status.value is TorServiceStatus.Active) return _status.value = TorServiceStatus.Connecting @@ -167,7 +167,7 @@ class TorService( * Stop the SOCKS proxy and release the port. * The TorClient stays alive — no file lock issues on restart. */ - suspend fun stop() { + override suspend fun stop() { if (!proxyRunning.compareAndSet(true, false)) return withContext(Dispatchers.IO) { @@ -177,4 +177,36 @@ class TorService( _status.value = TorServiceStatus.Off } + + /** + * Drop the native TorClient so the next [start] runs full initialization + * with a fresh bootstrap, new guards, and new circuits. Used by self-heal + * paths in [TorManager] — network identity change, stuck-Connecting + * recovery — when the in-memory Arti state is suspected of being broken. + * The `arti/state/` directory on disk is preserved. + */ + override suspend fun reset() { + withContext(Dispatchers.IO) { + if (proxyRunning.compareAndSet(true, false)) { + ArtiNative.stopSocksProxy() + } + ArtiNative.destroy() + initialized.set(false) + Log.d("TorService") { "Tor service reset — next start will re-initialize" } + } + _status.value = TorServiceStatus.Off + } + + /** + * Like [reset] but additionally wipes `arti/state/` so the next + * initialization rebuilds guard selection from scratch. Used when stale + * on-disk state (e.g. unreachable guards persisted from a previous + * network) is the suspected cause of a bootstrap that never completes. + */ + override suspend fun resetWithCleanState() { + reset() + withContext(Dispatchers.IO) { + clearAllArtiData() + } + } } diff --git a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so index 8fc5ef3339..b29a78dbe1 100755 Binary files a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so and b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/amethyst/src/main/jniLibs/x86_64/libarti_android.so b/amethyst/src/main/jniLibs/x86_64/libarti_android.so index 3ee9f24e99..4a473042a9 100755 Binary files a/amethyst/src/main/jniLibs/x86_64/libarti_android.so and b/amethyst/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt new file mode 100644 index 0000000000..df35673a61 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt @@ -0,0 +1,489 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +/** + * Tier-3 integration tests that drive the real Arti JNI shim on JVM, against the + * Linux x86_64 host build of the same wrapper crate that powers Android. The .so + * is checked in at `amethyst/src/test/native-libs/x86_64-linux/libarti_android.so` + * and the Gradle test task sets `java.library.path` to point at it. + * + * **Layered safety net for our Tor stack:** + * - [TorManagerTest] — fast unit tests, no Arti, virtual time. Covers Kotlin + * self-heal logic. + * - This file (smoke) — JNI bridge loads, version JNI call works. Always runs + * on Linux x86_64 hosts. ~10ms. Catches build/link regressions in the .so. + * - This file (integration) — opt-in via `-Pamethyst.arti.integration=true`. + * Real bootstrap + SOCKS round trips. Needs outbound TCP egress to arbitrary + * IPs/ports — works on most dev machines and Docker hosts with default + * networking; *will hang* on CI runners with restrictive egress lists. + * - `androidTest/.../tor/TorBootstrapInstrumentedTest` — same shape but against + * the Android .so on a connected device/emulator. + * + * **What the integration suite verifies that the unit tests cannot:** + * + * The original "Tor stops working until data-wipe" bug had four root causes that + * unit tests with a fake `TorBackend` can't exercise — they need the real Arti + * client, real circuits, real OS sockets: + * + * 1. The native `TorClient` getting stuck with bad guards / dead circuits / + * expired consensus, with no way to drop it in-process. Pre-fix there was + * no JNI `destroy()`. Verified by `destroy then re-initialize releases the + * state file lock cleanly`. + * + * 2. In-flight per-connection handlers each holding an `Arc` clone, + * pinning the state file lock past `destroy()`. Pre-fix the handler + * tracking was racy. Verified by `destroy aborts an in-flight SOCKS handler`. + * + * 3. `stopSocksProxy` deliberately not destroying the client (by design — for + * the legitimate stop/start reuse path), so a stuck client survived + * toggle-off-then-on. Verified by `stopSocksProxy then startSocksProxy + * reuses the running TorClient`. + * + * 4. State / fd / memory leaks accumulating across many destroy/init cycles + * (which the self-heal watchdog can drive at up to one per 5 minutes + * indefinitely). Verified by `survives multiple destroy then initialize + * cycles`. + * + * **Run the slow tests:** + * ``` + * ./gradlew :amethyst:testPlayDebugUnitTest \ + * --tests "com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest" \ + * -Pamethyst.arti.integration=true + * ``` + */ +class TorArtiNativeIntegrationTest { + private var dataDir: File? = null + + @After + fun tearDown() { + // Drop the in-process client between tests so the state file lock + // doesn't bleed across (and our tests stay independent). Idempotent — + // no-op if initialize never ran. + try { + ArtiNative.destroy() + } catch (_: Throwable) { + // Library may not have loaded if assumeArchAvailable skipped us. + } + dataDir?.deleteRecursively() + dataDir = null + } + + // --------------------------------------------------------------------- + // Smoke — runs without -P. Catches build/link regressions. + // --------------------------------------------------------------------- + + /** + * The host `.so` loads via `System.loadLibrary("arti_android")` and a trivial + * JNI function returns. If this fails, every other Tor test is moot — typical + * causes are a stale `.so` after an arti version bump, a missing rebuild on + * the test native-libs path, or a build that didn't export the expected JNI + * symbol. Always runs (no `-P` gate). + */ + @Test + fun `library loads and reports a version`() { + assumeArchAvailable() + val version = ArtiNative.getVersion() + assertTrue("Version string was: $version", version.startsWith("Arti ")) + println("[arti] $version") + } + + // --------------------------------------------------------------------- + // Bootstrap + round trip — the basic data plane. + // --------------------------------------------------------------------- + + /** + * Real bootstrap + SOCKS round trip. Regression net for: rustls + * `CryptoProvider` install after the v2.3.0 bump, `fs-mistrust` host-trust + * override on JVM, and every `Java_..._ArtiNative_*` JNI export. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `bootstraps and proxies an HTTPS request through Tor`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + val exitIp = fetchExitIp(port) + println("[test] First-bootstrap exit IP: $exitIp") + assertNotNull(exitIp) + } + + // --------------------------------------------------------------------- + // destroy + re-init releases state file lock — root cause #1. + // --------------------------------------------------------------------- + + /** + * After `destroy`, the *same* on-disk data dir must be re-acquirable by a + * fresh `initialize` — no "state file already locked" error, no need to + * `clearAllArtiData`. This is the direct mirror of the self-heal recovery + * path that the watchdog drives on stuck-Connecting. + */ + @Test(timeout = (BOOTSTRAP_TIMEOUT_MS * 2) + 60_000L) + fun `destroy then re-initialize releases the state file lock cleanly`() { + assumeFullIntegration() + val firstPort = bootstrapAndStartSocks() + val firstIp = fetchExitIp(firstPort) + println("[test] Pre-destroy exit IP: $firstIp") + + val destroyResult = ArtiNative.destroy() + assertEquals("destroy returned non-zero", 0, destroyResult) + + // Re-init against the SAME data dir. Must NOT see "state file already locked". + assertEquals( + "re-initialize after destroy should succeed without clearing data", + 0, + ArtiNative.initialize(dataDir!!.absolutePath), + ) + val secondPort = pickPort() + assertEquals("post-destroy startSocksProxy", 0, ArtiNative.startSocksProxy(secondPort)) + + val secondIp = fetchExitIp(secondPort) + println("[test] Post-destroy exit IP: $secondIp (changed=${firstIp != secondIp})") + assertNotNull(secondIp) + } + + // --------------------------------------------------------------------- + // In-flight handler abort — root cause #2. + // --------------------------------------------------------------------- + + /** + * If a SOCKS connection is open at the moment we call [ArtiNative.destroy], + * the handler's `Arc` clone must be released — otherwise the + * `TorClient` stays alive past `destroy()`, the state file lock isn't + * released, and the next `initialize()` fails with "already locked". + * + * Pre-fix the handler-task tracking was racy: a connection accepted between + * `SOCKS_TASK.abort()` and the `HANDLER_TASKS` drain pinned an Arc. This + * test exercises that race window directly. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `destroy aborts an in-flight SOCKS handler quickly`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + // Open a long-lived SOCKS connection. We don't actually read the body — + // we just want a handler to be alive in tokio-land when destroy hits. + val socksClient = socksOkHttp(port, readTimeoutSeconds = 300L) + val inFlight = + Thread { + try { + // Hit a deliberately slow path. The fact that it never returns + // is fine — we're going to destroy() out from under it. + socksClient + .newCall( + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build(), + ).execute() + .use { resp -> + @Suppress("UNUSED_VARIABLE") + val ignored = resp.body.string() + } + } catch (e: Throwable) { + println("[test] In-flight request aborted with: ${e.javaClass.simpleName}: ${e.message}") + } + } + inFlight.name = "in-flight-socks-request" + inFlight.start() + + // Let the handler get into client.connect() or io::copy. + Thread.sleep(1_500) + + // destroy() must return in roughly its budgeted time even with traffic + // in flight: ~1s wait for SOCKS_TASK termination + 500ms sleep for + // handler cleanup, plus some slack. + val destroyMs = + measureTimeMillis { + assertEquals(0, ArtiNative.destroy()) + } + println("[test] destroy() with in-flight handler returned in ${destroyMs}ms") + assertTrue("destroy took ${destroyMs}ms, expected < 3000ms", destroyMs < 3_000) + + // The in-flight thread should die promptly once its socket gets aborted. + inFlight.join(5_000) + assertTrue("In-flight request thread still alive after destroy", !inFlight.isAlive) + + // The critical assertion: the state file lock was released, so a fresh + // initialize against the SAME data dir works. Pre-fix this would fail + // because the orphaned handler still held an Arc. + val reinitMs = + measureTimeMillis { + assertEquals( + "re-initialize after destroy-with-in-flight should succeed", + 0, + ArtiNative.initialize(dataDir!!.absolutePath), + ) + } + println("[test] re-initialize after in-flight-destroy completed in ${reinitMs}ms") + } + + // --------------------------------------------------------------------- + // stop/start reuse — root cause #3 (negative test). + // --------------------------------------------------------------------- + + /** + * The legitimate stop/start reuse path: stopSocksProxy releases the SOCKS + * port but keeps the TorClient alive, and startSocksProxy on a fresh port + * binds against the same client without re-bootstrapping. This is the + * pattern the user-facing toggle uses; we just verify it still works after + * our self-heal changes. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `stopSocksProxy then startSocksProxy reuses the running TorClient`() { + assumeFullIntegration() + val firstPort = bootstrapAndStartSocks() + val firstIp = fetchExitIp(firstPort) + println("[test] Pre-stop exit IP: $firstIp") + + assertEquals(0, ArtiNative.stopSocksProxy()) + + // No new bootstrap should happen — the second startSocksProxy reuses + // the client. So this round trip should be fast (no consensus download). + val secondPort = pickPort() + val secondStartMs = + measureTimeMillis { + assertEquals(0, ArtiNative.startSocksProxy(secondPort)) + } + assertTrue( + "startSocksProxy on existing client took ${secondStartMs}ms — should be fast (no re-bootstrap)", + secondStartMs < 5_000, + ) + + val secondIp = fetchExitIp(secondPort) + println("[test] Post-stop/start exit IP: $secondIp (${secondStartMs}ms to re-bind)") + assertNotNull(secondIp) + } + + // --------------------------------------------------------------------- + // Many cycles — root cause #4 (gradual degradation). + // --------------------------------------------------------------------- + + /** + * The self-heal watchdog can drive `destroy → initialize` cycles up to once + * per 5 minutes for the entire app lifetime. Even at one per hour that's + * thousands of cycles before a user might restart the process. Verify we + * don't accumulate state corruption, file-descriptor leaks, or memory + * leaks across a handful of cycles. + * + * Bumped from 1 to [CYCLE_COUNT] cycles because 2 cycles (`destroy then + * re-initialize releases the state file lock cleanly`) is enough to catch + * the basic regression, but only 5+ catches gradual drift. + */ + @Test(timeout = (BOOTSTRAP_TIMEOUT_MS * CYCLE_COUNT) + 90_000L) + fun `survives multiple destroy then initialize cycles`() { + assumeFullIntegration() + dataDir = Files.createTempDirectory("arti-integ-cycles-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + + val ips = mutableListOf() + for (i in 1..CYCLE_COUNT) { + var ip: String? = null + val cycleMs = + measureTimeMillis { + assertEquals("cycle $i initialize", 0, ArtiNative.initialize(dataDir!!.absolutePath)) + val port = pickPort() + assertEquals("cycle $i startSocksProxy", 0, ArtiNative.startSocksProxy(port)) + ip = fetchExitIp(port) + ips += ip ?: "?" + assertEquals("cycle $i destroy", 0, ArtiNative.destroy()) + } + println("[test] Cycle $i/$CYCLE_COUNT exit=$ip in ${cycleMs}ms") + } + // No hard assertion on IPs being different — Tor's exit selection isn't + // deterministic and small cycles can repeat exits. We just log them so + // the developer can see circuit variety. + println("[test] All ${CYCLE_COUNT} cycles completed. Exit IPs: $ips") + } + + // --------------------------------------------------------------------- + // Concurrent SOCKS — accept loop + handler tracking under load. + // --------------------------------------------------------------------- + + /** + * Verify that multiple in-flight SOCKS connections can coexist. This + * exercises: + * - the Rust accept loop pushing to `HANDLER_TASKS` (incl. its retain-on-push + * dedup of finished handles), + * - per-handler `Arc` clones being independent, + * - the listener handling several `accept().await` rounds back-to-back. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 120_000L) + fun `proxies concurrent SOCKS requests in parallel`() { + assumeFullIntegration() + val port = bootstrapAndStartSocks() + + val concurrency = 5 + val ips = + runBlocking { + (1..concurrency) + .map { + async(Dispatchers.IO) { + fetchExitIp(port) + } + }.awaitAll() + } + ips.forEachIndexed { i, ip -> + println("[test] Concurrent request ${i + 1}/$concurrency exit: $ip") + } + assertTrue("All concurrent requests should return an IP", ips.all { it != null }) + } + + // --------------------------------------------------------------------- + // destroy idempotency. + // --------------------------------------------------------------------- + + /** + * Calling `destroy()` when the client is already destroyed (or was never + * initialized) should be a safe no-op. Pre-fix, a double-destroy could + * panic on the Rust side because of unwrap on an already-`None` Option. + * Now it's idempotent. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `destroy is idempotent`() { + assumeFullIntegration() + // Destroy before any initialize — should be a no-op. + assertEquals("destroy on uninitialized client", 0, ArtiNative.destroy()) + + // Initialize, then destroy twice. + dataDir = Files.createTempDirectory("arti-integ-idem-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + assertEquals(0, ArtiNative.initialize(dataDir!!.absolutePath)) + assertEquals("first destroy", 0, ArtiNative.destroy()) + assertEquals("second destroy is a no-op", 0, ArtiNative.destroy()) + + // After two destroys, a fresh initialize still works. + assertEquals("initialize after double-destroy", 0, ArtiNative.initialize(dataDir!!.absolutePath)) + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private fun bootstrapAndStartSocks(): Int { + dataDir = Files.createTempDirectory("arti-integ-").toFile() + ArtiNative.setLogCallback { line -> println("[arti] $line") } + val bootstrapMs = + measureTimeMillis { + val initResult = ArtiNative.initialize(dataDir!!.absolutePath) + assertEquals("initialize returned $initResult", 0, initResult) + } + val port = pickPort() + val socksResult = ArtiNative.startSocksProxy(port) + assertEquals("startSocksProxy returned $socksResult", 0, socksResult) + println("[test] Bootstrap+startSocks complete in ${bootstrapMs}ms on port $port") + return port + } + + /** + * Fetches `https://check.torproject.org/api/ip` through the given SOCKS port + * and returns the reported exit IP, or `null` if the response is malformed. + * Asserts the request succeeded — callers can `assertNotNull` if they care + * about the IP itself. + */ + private fun fetchExitIp(port: Int): String? { + val client = socksOkHttp(port) + val body = + client + .newCall( + Request + .Builder() + .url("https://check.torproject.org/api/ip") + .build(), + ).execute() + .use { resp -> + assertEquals("HTTP 200 from check.torproject.org", 200, resp.code) + resp.body.string() + } + assertTrue( + "Response should report IsTor:true — body was: $body", + body.contains("\"IsTor\":true"), + ) + return EXIT_IP_REGEX.find(body)?.groupValues?.getOrNull(1) + } + + private fun socksOkHttp( + port: Int, + readTimeoutSeconds: Long = 60L, + ): OkHttpClient = + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) + .build() + + private fun pickPort(): Int = (40_000..49_999).random() + + /** Composite of `assumeArchAvailable` + `assumeIntegrationEnabled`. */ + private fun assumeFullIntegration() { + assumeArchAvailable() + assumeIntegrationEnabled() + } + + /** + * The checked-in test .so is built for Linux x86_64 only. Skip on other + * hosts rather than failing — a developer on macOS or aarch64 shouldn't see + * a build break just because they ran the full test suite. + */ + private fun assumeArchAvailable() { + val arch = System.getProperty("os.arch")?.lowercase().orEmpty() + val os = System.getProperty("os.name")?.lowercase().orEmpty() + assumeTrue( + "Test .so is provided only for Linux x86_64 (was: $os $arch). " + + "To run elsewhere, rebuild with `./tools/arti-build/build-arti-host.sh`.", + os.contains("linux") && (arch == "amd64" || arch == "x86_64"), + ) + } + + private fun assumeIntegrationEnabled() { + assumeTrue( + "Set -Pamethyst.arti.integration=true to enable. Needs network egress " + + "to arbitrary IPs/ports (Tor directory authorities + guards) — restrictive " + + "CI runners will hang in initialize().", + System.getProperty("amethyst.arti.integration") == "true", + ) + } + + companion object { + private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L + private const val CYCLE_COUNT: Int = 5 + private val EXIT_IP_REGEX = """"IP"\s*:\s*"([^"]+)"""".toRegex() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt new file mode 100644 index 0000000000..8afa86072e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [TorManager]'s self-heal logic. Drives the manager with in-memory + * [TorBackend] + [TorPreferencesPort] fakes and a virtual clock so the 45s watchdog + * delay and 5-min cooldown can be exercised in milliseconds. + * + * Companion integration test in `amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt` + * covers the real-Arti bootstrap path on-device (currently @Ignore'd; see file for enable steps). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TorManagerTest { + // ------------------------------------------------------------------ + // construction + persisted state + // ------------------------------------------------------------------ + + @Test + fun `init loads persisted bypass approval`() = + runTest(UnconfinedTestDispatcher()) { + val recent = 1_000_000_000_000L + val prefs = FakeTorPreferences(initialApprovalMs = recent) + val manager = buildManager(prefs = prefs, clock = { recent + 1_000L }) + + advanceUntilIdle() + + assertTrue(manager.rememberedApprovalActive()) + } + + @Test + fun `init does not flag approval when none persisted`() = + runTest(UnconfinedTestDispatcher()) { + val manager = buildManager() + advanceUntilIdle() + + assertFalse(manager.rememberedApprovalActive()) + } + + @Test + fun `rememberedApprovalActive is false once outside the 1h window`() = + runTest(UnconfinedTestDispatcher()) { + val now = 1_000_000_000_000L + val tooOld = now - TorManager.APPROVAL_REMEMBER_MS - 1L + val prefs = FakeTorPreferences(initialApprovalMs = tooOld) + val manager = buildManager(prefs = prefs, clock = { now }) + + advanceUntilIdle() + + assertFalse(manager.rememberedApprovalActive()) + } + + // ------------------------------------------------------------------ + // torType change clears the bypass loop + // ------------------------------------------------------------------ + + @Test + fun `torType change clears in-memory bypass and persisted approval`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialApprovalMs = 999L) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + manager.sessionBypass.value = true + + prefs.setTorType(TorType.OFF) + advanceUntilIdle() + + assertFalse(manager.sessionBypass.value) + assertEquals(0L, prefs.lastBypassApprovalMs) + } + + @Test + fun `approveBypassForOneHour sets sessionBypass and persists timestamp`() = + runTest(UnconfinedTestDispatcher()) { + val now = 1_000_000_000_000L + val prefs = FakeTorPreferences() + val manager = buildManager(prefs = prefs, clock = { now }) + advanceUntilIdle() + + manager.approveBypassForOneHour() + advanceUntilIdle() + + assertTrue(manager.sessionBypass.value) + assertEquals(now, prefs.lastBypassApprovalMs) + assertTrue(manager.rememberedApprovalActive()) + } + + // ------------------------------------------------------------------ + // onNetworkChange — drops client + clears bypass + primes cooldown + // ------------------------------------------------------------------ + + @Test + fun `onNetworkChange clears bypass and persisted approval and resets backend`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialApprovalMs = 12345L) + val backend = FakeTorBackend() + val manager = buildManager(prefs = prefs, backend = backend) + advanceUntilIdle() + manager.sessionBypass.value = true + + manager.onNetworkChange() + advanceUntilIdle() + + assertFalse(manager.sessionBypass.value) + assertEquals(0L, prefs.lastBypassApprovalMs) + assertTrue("onNetworkChange should reset backend at least once", backend.resetCount >= 1) + } + + @Test + fun `onNetworkChange primes cooldown so the watchdog does not double-reset`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + // Constant clock — the only way self-heal would fire is if onNetworkChange + // failed to prime lastSelfHealAtMs. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + val resetCountBefore = backend.resetCount + + manager.onNetworkChange() + advanceUntilIdle() + + // Status is back at Connecting after the network-change reset cycle. + // Advance past the 45s watchdog; cooldown must suppress a second reset. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + // Exactly one extra reset from onNetworkChange itself, none from the watchdog. + assertEquals(resetCountBefore + 1, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + // ------------------------------------------------------------------ + // stuck-Connecting watchdog + // ------------------------------------------------------------------ + + @Test + fun `watchdog uses gentle reset before first Active`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + // Big constant clock so (now - lastSelfHealAtMs=0) is well past cooldown. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + assertEquals(0, backend.resetCount) + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("gentle reset only — no state wipe before first Active", 1, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog uses full reset after first Active`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + // Drive backend to Active so hasEverBootstrapped flips. + backend.setActive(9050) + advanceUntilIdle() + assertTrue(manager.status.value is TorServiceStatus.Active) + + // Back to Connecting — watchdog timer (re-)starts. + backend.setConnecting() + advanceUntilIdle() + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals(0, backend.resetCount) + assertEquals("after Active, watchdog wipes state too", 1, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog cancels its delay when status leaves Connecting`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + // Halfway through the watchdog delay, the bootstrap succeeds. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS / 2) + backend.setActive(9050) + advanceUntilIdle() + + // Past the original deadline — must NOT fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS) + runCurrent() + + assertEquals(0, backend.resetCount) + assertEquals(0, backend.resetWithCleanStateCount) + } + + @Test + fun `watchdog cooldown blocks a second fire within the window`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + var clockNow = 1_000_000_000_000L + val manager = buildManager(backend = backend, clock = { clockNow }) + advanceUntilIdle() + + // First fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals(1, backend.resetCount) + + // Status returns to Connecting via the reset → re-start cycle. Advance another + // 45s of virtual time — clock has barely moved, so cooldown must block. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals("cooldown should suppress the second fire", 1, backend.resetCount) + } + + @Test + fun `watchdog can fire again once the cooldown elapses`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + var clockNow = 1_000_000_000_000L + val manager = buildManager(backend = backend, clock = { clockNow }) + advanceUntilIdle() + + // First fire. + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + assertEquals(1, backend.resetCount) + + // Move wall-clock past the cooldown window. + clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("after cooldown elapses, watchdog fires again", 2, backend.resetCount) + } + + // ------------------------------------------------------------------ + // top-level status routing + // ------------------------------------------------------------------ + + @Test + fun `status emits Off when torType is OFF`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.OFF) + val backend = FakeTorBackend() + val manager = buildManager(prefs = prefs, backend = backend) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + assertEquals(0, backend.startCount) + } + + @Test + fun `status emits Active(port) for EXTERNAL with valid port`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 9150) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + val status = manager.status.value + assertTrue(status is TorServiceStatus.Active) + assertEquals(9150, (status as TorServiceStatus.Active).port) + } + + @Test + fun `status emits Off for EXTERNAL when port is invalid`() = + runTest(UnconfinedTestDispatcher()) { + val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 0) + val manager = buildManager(prefs = prefs) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + } + + @Test + fun `status follows backend status under INTERNAL`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + + backend.setActive(17392) + advanceUntilIdle() + assertEquals(TorServiceStatus.Active(17392), manager.status.value) + } + + @Test + fun `activePortOrNull mirrors the Active port`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + // activePortOrNull is WhileSubscribed — give it a subscriber for the test. + val portJob = backgroundScope.launch { manager.activePortOrNull.collect {} } + advanceUntilIdle() + + backend.setActive(17392) + advanceUntilIdle() + + assertEquals(17392, manager.activePortOrNull.value) + portJob.cancel() + } + + @Test + fun `sessionBypass forces Off even with torType INTERNAL`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend() + val manager = buildManager(backend = backend) + advanceUntilIdle() + backend.setActive(17392) + advanceUntilIdle() + assertNotEquals(TorServiceStatus.Off, manager.status.value) + + manager.sessionBypass.value = true + advanceUntilIdle() + + assertEquals(TorServiceStatus.Off, manager.status.value) + assertTrue(backend.stopCount >= 1) + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private fun TestScope.buildManager( + prefs: FakeTorPreferences = FakeTorPreferences(), + backend: FakeTorBackend = FakeTorBackend(), + clock: () -> Long = { 1_000_000_000_000L }, + ): TorManager = + TorManager( + torPrefs = prefs, + service = backend, + scope = backgroundScope, + // Unconfined so `MutableStateFlow.value = …` propagates through `flowOn` + // synchronously — otherwise advanceUntilIdle never settles the cross-dispatcher + // channel and `manager.status.value` is observed as the stateIn initial (Off). + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + nowMs = clock, + ) +} + +/** In-memory [TorBackend] driven by tests. */ +private class FakeTorBackend : TorBackend { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + var startCount = 0 + private set + var stopCount = 0 + private set + var resetCount = 0 + private set + var resetWithCleanStateCount = 0 + private set + + override suspend fun start() { + startCount++ + _status.value = TorServiceStatus.Connecting + } + + override suspend fun stop() { + stopCount++ + _status.value = TorServiceStatus.Off + } + + override suspend fun reset() { + resetCount++ + _status.value = TorServiceStatus.Off + } + + override suspend fun resetWithCleanState() { + resetWithCleanStateCount++ + _status.value = TorServiceStatus.Off + } + + fun setActive(port: Int) { + _status.value = TorServiceStatus.Active(port) + } + + fun setConnecting() { + _status.value = TorServiceStatus.Connecting + } +} + +/** In-memory [TorPreferencesPort] driven by tests. */ +private class FakeTorPreferences( + initialTorType: TorType = TorType.INTERNAL, + initialPort: Int = 9050, + initialApprovalMs: Long = 0L, +) : TorPreferencesPort { + private val _torType = MutableStateFlow(initialTorType) + private val _externalSocksPort = MutableStateFlow(initialPort) + + override val torType: StateFlow = _torType.asStateFlow() + override val externalSocksPort: StateFlow = _externalSocksPort.asStateFlow() + + var lastBypassApprovalMs: Long = initialApprovalMs + + override suspend fun loadLastBypassApprovalMs(): Long = lastBypassApprovalMs + + override suspend fun saveLastBypassApprovalMs(value: Long) { + lastBypassApprovalMs = value + } + + fun setTorType(value: TorType) { + _torType.value = value + } +} diff --git a/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so new file mode 100755 index 0000000000..ef6d500311 Binary files /dev/null and b/amethyst/src/test/native-libs/x86_64-linux/libarti_android.so differ diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION index c0095959b7..5c804c82cf 100644 --- a/tools/arti-build/ARTI_VERSION +++ b/tools/arti-build/ARTI_VERSION @@ -1 +1 @@ -arti-v2.2.0 +arti-v2.3.0 \ No newline at end of file diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml index dccb35ba13..b7d8c377e8 100644 --- a/tools/arti-build/Cargo.toml +++ b/tools/arti-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "arti-android" -version = "2.2.0" +version = "2.3.0" edition = "2021" [lib] @@ -9,14 +9,19 @@ crate-type = ["cdylib"] [workspace] [dependencies] -arti-client = { version = "0.41", default-features = false, features = [ +arti-client = { version = "0.42", default-features = false, features = [ "tokio", "rustls", "compression", "onion-service-client", "static-sqlite", ] } -tor-rtcompat = { version = "0.41", default-features = false, features = ["tokio", "rustls"] } +tor-rtcompat = { version = "0.42", default-features = false, features = ["tokio", "rustls"] } +# Direct dep on rustls so we can install the `ring` crypto provider ourselves — +# arti-v2.3.0's tor-rtcompat no longer installs one implicitly. `ring` matches +# what arti-v2.2.0 effectively used and avoids the Android build pain of +# aws-lc-rs (which became Arti's default in 2.3.0). +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } jni = "0.21" tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] } anyhow = "1" diff --git a/tools/arti-build/build-arti-host.sh b/tools/arti-build/build-arti-host.sh new file mode 100755 index 0000000000..219adeab2d --- /dev/null +++ b/tools/arti-build/build-arti-host.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Build the Arti JNI shim for the *host* (typically Linux x86_64) and stage it +# under amethyst/src/test/native-libs//libarti_android.so so the JVM +# unit tests in TorArtiNativeIntegrationTest can `System.loadLibrary` it. +# +# Companion to build-arti.sh, which builds the *Android* targets for shipping +# in the APK. Same wrapper crate, same lib.rs — only the cargo target differs. +# +# Prerequisites: +# - Rust toolchain with the host target installed (default after `rustup install stable`). +# - The Arti source must already be cloned at .arti-source/ — run build-arti.sh +# once first if this is a fresh checkout. +# +# Usage: +# ./build-arti-host.sh +# +# Why this exists: +# Tier-3 JVM integration tests in amethyst/src/test/.../tor/TorArtiNativeIntegrationTest +# call the real Arti library. The checked-in .so under src/test/native-libs/x86_64-linux/ +# covers the most common dev/CI host. If you bump ARTI_VERSION or touch +# tools/arti-build/src/lib.rs, regenerate the host .so with this script before +# running the integration tests; otherwise you'll be testing the previous shim. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WRAPPER_DIR="$SCRIPT_DIR/.arti-source/arti-android-wrapper" + +if [ ! -d "$WRAPPER_DIR" ]; then + echo "Arti source / wrapper not found at $WRAPPER_DIR." + echo "Run ./build-arti.sh first (clones .arti-source and sets up the wrapper)." + exit 1 +fi + +# Sync the latest wrapper sources into the .arti-source clone — build-arti.sh +# normally does this, but if you've only edited lib.rs the host build needs it too. +cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/lib.rs" + +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +case "$HOST_TARGET" in + x86_64-unknown-linux-gnu) DEST_TAG="x86_64-linux" ;; + aarch64-unknown-linux-gnu) DEST_TAG="aarch64-linux" ;; + x86_64-apple-darwin) DEST_TAG="x86_64-macos" ;; + aarch64-apple-darwin) DEST_TAG="aarch64-macos" ;; + *) + echo "Unmapped host target $HOST_TARGET — add it to build-arti-host.sh." + exit 1 + ;; +esac + +OUT_DIR="$PROJECT_ROOT/amethyst/src/test/native-libs/$DEST_TAG" +mkdir -p "$OUT_DIR" + +echo "Building Arti shim for $HOST_TARGET → $OUT_DIR/libarti_android.so" +cargo build --release \ + --manifest-path "$WRAPPER_DIR/Cargo.toml" \ + --target "$HOST_TARGET" + +# macOS Rust toolchains produce .dylib, not .so. Rename so the existing +# System.loadLibrary("arti_android") path keeps working. +case "$HOST_TARGET" in + *-apple-darwin) + src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.dylib" + ;; + *) + src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.so" + ;; +esac + +cp "$src" "$OUT_DIR/libarti_android.so" +size=$(du -h "$OUT_DIR/libarti_android.so" | cut -f1) +echo "Built $OUT_DIR/libarti_android.so ($size)" +echo "" +echo "Run the smoke test:" +echo " ./gradlew :amethyst:testPlayDebugUnitTest \\" +echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest" +echo "" +echo "Run the full bootstrap tests (needs Tor network egress):" +echo " ./gradlew :amethyst:testPlayDebugUnitTest \\" +echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest \\" +echo " -Pamethyst.arti.integration=true" diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index 71c3136194..7e403eb7c3 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -195,6 +195,7 @@ verify_jni_symbols() { "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize" "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy" "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy" ) for arch_dir in "$OUTPUT_DIR"/*/; do diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs index 20a7a43d11..81883a31da 100644 --- a/tools/arti-build/src/lib.rs +++ b/tools/arti-build/src/lib.rs @@ -20,6 +20,10 @@ static TOKIO_RUNTIME: Mutex> = Mutex::new(None); static JAVA_VM: Mutex> = Mutex::new(None); static LOG_CALLBACK: Mutex> = Mutex::new(None); static SOCKS_TASK: Mutex>> = Mutex::new(None); +// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers +// — otherwise their Arc clones keep the client alive and the +// state file lock would not be released for the next initialize(). +static HANDLER_TASKS: Mutex>> = Mutex::new(Vec::new()); static INIT_ONCE: Once = Once::new(); // ============================================================================ @@ -127,6 +131,12 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( log_info!("Initializing Arti with data directory: {}", data_dir_str); INIT_ONCE.call_once(|| { + // arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider + // implicitly — without this, TorClient::create_bootstrapped panics on + // first TLS handshake. install_default() returns Err if a provider is + // already installed, which is fine; we just want at-least-one. + let _ = rustls::crypto::ring::default_provider().install_default(); + match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -160,8 +170,21 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( let result: Result<()> = runtime.block_on(async { log_info!("Creating Arti client..."); - let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) - .build()?; + let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir); + + // Arti's fs-mistrust walks every parent of the state dir and rejects any + // that has an "unsafe" owner. On Android the app's private filesDir is + // sandboxed by the OS, so the default strict check is correct. On JVM + // host runs (TorArtiNativeIntegrationTest) the data dir lives under + // /tmp and the check trips on container-style ownership of `/` (UID 999 + // etc.). Disable it for non-Android targets — these are the test/dev + // surface, not a user-facing binary. + #[cfg(not(target_os = "android"))] + { + builder.storage().permissions().dangerously_trust_everyone(); + } + + let config = builder.build()?; let client = TorClient::create_bootstrapped(config).await?; @@ -243,11 +266,14 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksPr match listener.accept().await { Ok((stream, _peer_addr)) => { let client_clone = Arc::clone(&client); - tokio::spawn(async move { + let h = tokio::spawn(async move { if let Err(e) = handle_socks_connection(stream, client_clone).await { log_error!("SOCKS connection error: {:?}", e); } }); + let mut handlers = HANDLER_TASKS.lock().unwrap(); + handlers.retain(|h| !h.is_finished()); + handlers.push(h); } Err(e) => { log_error!("Failed to accept SOCKS connection: {:?}", e); @@ -373,8 +399,13 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro handle.abort(); } - if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { - rt.block_on(async { + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .as_ref() + .map(|rt| rt.handle().clone()); + if let Some(rh) = rt_handle { + rh.block_on(async { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; }); } @@ -383,4 +414,66 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro log_info!("SOCKS proxy stopped"); 0 +} + +/// Destroy the TorClient — used by self-heal paths in Kotlin when Tor is +/// stuck and the in-memory state (guards, circuits) needs to be rebuilt +/// from scratch. Aborts the SOCKS listener and all in-flight per-connection +/// handlers, then drops the static Arc so Arti's state file lock can be +/// released. The next call to [initialize] will create a fresh TorClient +/// (and re-bootstrap). +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("Destroying Arti client"); + + // Clone the runtime handle and release the TOKIO_RUNTIME mutex immediately — + // we will hold it for ~500ms below, and other JNI calls that need the runtime + // (e.g. a Kotlin start() racing with us) would otherwise block on this mutex. + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .as_ref() + .map(|rt| rt.handle().clone()); + + // Abort the listener and wait for it to actually terminate before draining + // HANDLER_TASKS. The accept loop has no .await between `accept` and + // `HANDLER_TASKS.push(h)`, so abort() alone is racy: a handler can be spawned + // and pushed AFTER our drain. Awaiting the JoinHandle (with timeout) closes + // that window — no new handlers can be pushed once the listener task is gone. + let socks_handle = SOCKS_TASK.lock().unwrap().take(); + if let (Some(h), Some(rh)) = (socks_handle, rt_handle.as_ref()) { + h.abort(); + rh.block_on(async { + let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), h).await; + }); + } + + // Abort all in-flight handlers — each holds an Arc clone, and + // the client cannot drop (state file lock cannot release) while any clone + // is alive. + let handlers = std::mem::take(&mut *HANDLER_TASKS.lock().unwrap()); + for h in &handlers { + h.abort(); + } + drop(handlers); + + // Give tokio a moment to actually cancel and drop the task frames so the + // handler Arcs are released before we drop our static one. + if let Some(rh) = rt_handle { + rh.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + }); + } + + // Drop the static Arc. If any handler is still holding a clone, the + // TorClient stays alive until that handler finishes — in which case the + // next initialize() will fail and Kotlin's clearAllArtiData retry path + // will handle it. + let _ = ARTI_CLIENT.lock().unwrap().take(); + + log_info!("Arti client destroyed"); + 0 } \ No newline at end of file