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/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..c16763c826 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorArtiNativeIntegrationTest.kt @@ -0,0 +1,198 @@ +/* + * 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 okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +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 + +/** + * 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 (bootstrap) — opt-in via `-Pamethyst.arti.integration=true`. Hits + * `check.torproject.org` through real Tor. ~30-90s per test. 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. + * + * **Run the slow ones:** + * ``` + * ./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() + } + + /** + * Always-on smoke check: the host .so loads via `System.loadLibrary("arti_android")` + * (configured by the Gradle test task's `java.library.path`) 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. + */ + @Test + fun `library loads and reports a version`() { + assumeArchAvailable() + val version = ArtiNative.getVersion() + assertTrue("Version string was: $version", version.startsWith("Arti ")) + } + + /** + * Real bootstrap + SOCKS round trip. Regression net for: rustls + * `CryptoProvider` install after the v2.3.0 bump, `fs-mistrust` host-trust + * override, every Java_..._ArtiNative_* JNI export. Opt-in because it costs + * 30-90s and needs network egress that not every CI grants. + */ + @Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L) + fun `bootstraps and proxies an HTTPS request through Tor`() { + assumeArchAvailable() + assumeIntegrationEnabled() + dataDir = Files.createTempDirectory("arti-integ-").toFile() + + ArtiNative.setLogCallback { line -> println("[arti] $line") } + + 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) + + val body = fetchThroughSocks(port, "https://check.torproject.org/api/ip") + assertTrue( + "Response should report IsTor:true — body was: $body", + body.contains("\"IsTor\":true"), + ) + } + + /** + * Direct regression test for the destroy() / state-file-lock fix landed in + * the self-heal work: after destroy, the *same* on-disk data dir must be + * re-acquirable by a fresh initialize() — no lock-held error, no need to + * clearAllArtiData. + */ + @Test(timeout = (BOOTSTRAP_TIMEOUT_MS * 2) + 60_000L) + fun `destroy then re-initialize releases the state file lock cleanly`() { + assumeArchAvailable() + assumeIntegrationEnabled() + dataDir = Files.createTempDirectory("arti-integ-").toFile() + + ArtiNative.setLogCallback { line -> println("[arti] $line") } + assertEquals("first initialize", 0, ArtiNative.initialize(dataDir!!.absolutePath)) + assertEquals("first startSocksProxy", 0, ArtiNative.startSocksProxy(pickPort())) + + assertEquals("destroy returned non-zero", 0, ArtiNative.destroy()) + + // 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 body = fetchThroughSocks(secondPort, "https://check.torproject.org/api/ip") + assertTrue("post-re-init IsTor — body was: $body", body.contains("\"IsTor\":true")) + } + + private fun fetchThroughSocks( + port: Int, + url: String, + ): String { + val client = + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + return client.newCall(Request.Builder().url(url).build()).execute().use { + assertEquals("HTTP 200 from $url", 200, it.code) + it.body.string() + } + } + + private fun pickPort(): Int = (40_000..49_999).random() + + /** + * 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 `cargo build --release --target ` " + + "and place at amethyst/src/test/native-libs//libarti_android.so.", + 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 + } +} 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/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/src/lib.rs b/tools/arti-build/src/lib.rs index f0a7d05304..81883a31da 100644 --- a/tools/arti-build/src/lib.rs +++ b/tools/arti-build/src/lib.rs @@ -170,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?;