test(tor): tier-3 integration — JVM host build of Arti, smoke + bootstrap tests

Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.

Three tests in TorArtiNativeIntegrationTest:

1. `library loads and reports a version` — always-on smoke check. Loads
   libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
   ~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
   bump, a missing JNI symbol export, a forgotten rebuild on this path).
   Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
   build-arti-host.sh rebuild step.

2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
   -Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
   → OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
   Regression net for the rustls CryptoProvider install we added after the
   v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.

3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
   The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
   the SAME data dir with initialize again, verify it succeeds without a
   "state file already locked" error and that traffic still flows.

Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
  Cargo-builds the wrapper crate for the host target (x86_64-linux on most
  dev machines, but the script maps macOS / arm64-linux too) and copies to
  amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
  -Djava.library.path so System.loadLibrary("arti_android") finds the
  checked-in host .so. Also forwards -Pamethyst.arti.integration so the
  opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
  most common dev/CI host (~6 MB).

Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
  builder.storage().permissions().dangerously_trust_everyone() so Arti's
  fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
  directories have unusual UIDs (typical in containers). Android keeps its
  strict default — the app's private filesDir is already sandboxed by the OS.
  Compiled-out on Android, so the shipped Android .so is functionally
  unchanged.

Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
  on actual relay I/O in this container because outbound TCP egress is
  restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
  with unrestricted outbound — see the test kdoc.
This commit is contained in:
Claude
2026-05-26 21:34:26 +00:00
parent 3517606b81
commit e39ea55fd6
5 changed files with 311 additions and 2 deletions
+15
View File
@@ -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()) }
}
}
}
@@ -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 <host>` " +
"and place at amethyst/src/test/native-libs/<host>/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
}
}
Binary file not shown.
+83
View File
@@ -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/<host-tag>/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"
+15 -2
View File
@@ -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?;