mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+198
@@ -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.
Executable
+83
@@ -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"
|
||||
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user