build: update Arti to 2.6.0, jni to 0.22, NDK r30, Rust 1.98.1

Moves every pin in tools/arti-build forward and rebuilds all three shipped
libraries from them. Arti 2.5.0 carried two medium-severity fixes that a
client reaches in normal use, so the shipped 2.3.0 was the reason to do this
now: TROVE-2026-24, where a malicious directory mirror crashes the
tor-netdoc parser and eventually stops tor-dirmgr, and TROVE-2026-27, an
inefficient algorithm an attacker can drive into a CPU stall.

Pins:
- Arti 2.3.0 -> 2.6.0 (arti-client / tor-rtcompat 0.42 -> 0.46). New MSRV is
  1.91, satisfied by the pinned toolchain.
- Android NDK 27.3.13750724 (r27d) -> 30.0.16248370 (r30), the current LTS.
  clang and lld move 18.0.4 -> 21.0.0.
- rustc 1.94.1 -> 1.98.1.
- jni 0.21 -> 0.22.
- Cargo.lock regenerated, so every transitive dependency moves to its latest
  semver-compatible release. cargo-ndk was already on the pinned 4.1.2.

Source changes the upgrades required:
- Arti 2.4.0 made every TorClient constructor return an Arc<TorClient> and
  dropped Clone from TorClient, so the wrapper no longer wraps it itself.
- jni 0.22 splits the FFI environment pointer (EnvUnowned) from the API type
  (Env), which is only borrowed inside a closure. Native methods now acquire
  it via with_env and map failures through an ErrorPolicy instead of
  unwinding out of extern "C", which aborts. initialize() reads everything
  JNI-owned up front and resolves through Option<String>, because the policy
  default for jint is 0, the value that API reports as success. GlobalRef
  became Global<JObject>, thread attachment takes a closure (which also
  scopes a local-reference frame per log line), and the method name and
  signature are encoded at compile time via jni_str! / jni_sig!.

Verification:
- verify-reproducible.sh: two clean builds byte-for-byte identical, JNI
  symbols exported, 16 KiB LOAD alignment kept, both ABIs stamped r30, same
  libc/libm/libdl dependency set as before.
- JVM tier-3 smoke test passes against the rebuilt host shim, and a scratch
  harness drove setLogCallback, getVersion, initialize and destroy through
  real JNI: log lines arrive over the migrated callback and initialize
  returns 0.
- cargo audit: rsa 0.9.10 (RUSTSEC-2023-0071) remains, with no fixed version
  published upstream; it arrives via ssh-key-fork-arti and needs RSA private
  key operations, which a client without hosted onion services never does.
  The event-listener unsound and spin yanked warnings are gone.

Not verified here: the network-dependent integration tier and the on-device
instrumented test. This container blocks most outbound TCP (directory
authority port 9131 among them), so Tor circuits time out regardless of
which library is loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
This commit is contained in:
Claude
2026-09-13 18:51:50 +00:00
parent b52f5651de
commit e32cadc250
10 changed files with 926 additions and 662 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
27.3.13750724
30.0.16248370
+1 -1
View File
@@ -1 +1 @@
arti-v2.3.0
arti-v2.6.0
+821 -597
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "arti-android"
version = "2.3.0"
version = "2.6.0"
edition = "2021"
[lib]
@@ -9,20 +9,20 @@ crate-type = ["cdylib"]
[workspace]
[dependencies]
arti-client = { version = "0.42", default-features = false, features = [
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"rustls",
"compression",
"onion-service-client",
"static-sqlite",
] }
tor-rtcompat = { version = "0.42", default-features = false, features = ["tokio", "rustls"] }
tor-rtcompat = { version = "0.46", 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"
jni = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] }
anyhow = "1"
+4 -4
View File
@@ -11,7 +11,7 @@ JNI wrapper built directly from Arti source.
| **Size** | ~140MB | ~11MB |
| **16KB pages** | No | Yes (rustc aligns Android targets to 16 KiB) |
| **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) |
| **Version** | Behind | Pinned to latest (currently 1.9.0) |
| **Version** | Behind | Pinned to latest (see [`ARTI_VERSION`](ARTI_VERSION)) |
## Quick start
@@ -98,7 +98,7 @@ repo is checked out.
```
4. **Android NDK** — the exact revision in [`ANDROID_NDK_VERSION`](ANDROID_NDK_VERSION)
(currently **27.3.13750724**, r27d). Any other revision is refused: it would
(currently **30.0.16248370**, r30). Any other revision is refused: it would
produce a `.so` that does not match the committed one.
```bash
# Via Android Studio: SDK Manager → SDK Tools → NDK (Side by side)
@@ -168,7 +168,7 @@ readelf -p .note.android.ident amethyst/src/main/jniLibs/arm64-v8a/libarti_andro
readelf -p .comment amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so
```
For the pinned toolchain that prints `r27d` / `13750724`, clang 18.0.4 and the
For the pinned toolchain that prints `r30` / `16248370`, clang 21.0.0 and the
`rustc` version from `rust-toolchain.toml`. `build-arti.sh` runs the first check
itself after every build.
@@ -177,7 +177,7 @@ itself after every build.
```
tools/arti-build/
├── README.md # This file
├── ARTI_VERSION # Pinned Arti git tag (e.g., arti-v1.9.0)
├── ARTI_VERSION # Pinned Arti git tag (e.g., arti-v2.6.0)
├── ANDROID_NDK_VERSION # Pinned NDK revision — enforced by build-arti.sh (reproducibility)
├── CARGO_NDK_VERSION # cargo-ndk release the pinned output was verified with
├── rust-toolchain.toml # Pinned rustc version + Android targets (reproducibility)
+1 -1
View File
@@ -7,7 +7,7 @@
# build scripts. Bump this in lockstep with ARTI_VERSION / Cargo.lock and
# re-verify (see README.md → "Reproducible builds").
[toolchain]
channel = "1.94.1"
channel = "1.98.1"
profile = "minimal"
components = ["rustc", "cargo", "rust-std"]
# Only the two ABIs we actually ship libarti_android.so for (see jniLibs/). If
+94 -54
View File
@@ -1,6 +1,7 @@
use jni::JNIEnv;
use jni::objects::{JClass, JString, JObject, GlobalRef};
use jni::sys::{jint, jstring};
use jni::{jni_sig, jni_str, EnvUnowned};
use jni::errors::{LogErrorAndDefault, Result as JniResult, ThrowRuntimeExAndDefault};
use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::jint;
use jni::JavaVM;
use arti_client::{BootstrapBehavior, TorClient};
@@ -21,7 +22,7 @@ use anyhow::Result;
static ARTI_CLIENT: Mutex<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<Global<JObject<'static>>>> = Mutex::new(None);
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
// The background directory download started by initialize(). It holds an Arc<TorClient>, so
// destroy() must abort it too — otherwise the client cannot drop, the state file lock is never
@@ -42,16 +43,21 @@ fn send_log_to_java(message: String) {
let callback_opt = LOG_CALLBACK.lock().unwrap();
if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) {
if let Ok(mut env) = vm.attach_current_thread() {
// jni 0.22 only hands out an `Env` inside a closure, borrowed from an
// attachment pinned to the stack; it also pushes a local-reference frame
// per call, so `jmessage` is released when the closure returns instead of
// accumulating on this long-lived logging thread.
let _ = vm.attach_current_thread(|env| -> JniResult<()> {
if let Ok(jmessage) = env.new_string(&message) {
let _ = env.call_method(
callback.as_obj(),
"onLogLine",
"(Ljava/lang/String;)V",
&[(&jmessage).into()]
&**callback,
jni_str!("onLogLine"),
jni_sig!("(Ljava/lang/String;)V"),
&[(&jmessage).into()],
);
}
}
Ok(())
});
}
}
@@ -74,52 +80,87 @@ macro_rules! log_error {
// ============================================================================
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion(
env: JNIEnv,
_class: JClass,
) -> jstring {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
) -> JString<'caller> {
// jni 0.22: the raw environment pointer is FFI-only (`EnvUnowned`); JNI calls
// need the `Env` that `with_env` borrows for the closure. `resolve` maps an
// error to the policy — here a Java RuntimeException plus a null return —
// instead of unwinding out of `extern "C"`, which aborts the process.
env.with_env(|env| -> JniResult<JString<'caller>> {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
}
let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION"));
let output = env.new_string(version).expect("Couldn't create java string!");
output.into_raw()
let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION"));
env.new_string(version)
})
.resolve::<ThrowRuntimeExAndDefault>()
}
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback(
env: JNIEnv,
_class: JClass,
callback: JObject,
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
callback: JObject<'caller>,
) {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
env.with_env(|env| -> JniResult<()> {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
}
if let Ok(global_ref) = env.new_global_ref(callback) {
*LOG_CALLBACK.lock().unwrap() = Some(global_ref);
log_info!("Log callback registered");
}
if let Ok(global_ref) = env.new_global_ref(&callback) {
*LOG_CALLBACK.lock().unwrap() = Some(global_ref);
log_info!("Log callback registered");
}
Ok(())
})
.resolve::<ThrowRuntimeExAndDefault>()
}
/// Initialize Arti runtime and bootstrap the TorClient.
/// The TorClient is created once and reused for the app's lifetime.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
mut env: JNIEnv,
_class: JClass,
data_dir: JString,
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
data_dir: JString<'caller>,
) -> jint {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
// Everything JNI-owned is read inside this closure; the rest of the function
// is pure Rust that blocks on Tokio, which must not hold an `Env`.
//
// The policy only applies to a panic or an `Err` returned here, and `None`
// is `Option::default()`, so both of those land on the same `-1` the old
// `Err` arm returned. A jint policy default would have been `0`, which this
// API reports as success.
let data_dir_str: Option<String> = env
.with_env(|env| -> JniResult<Option<String>> {
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
// Already initialized — the caller-visible "reuse" path still has to
// run below, so signal it with an empty string rather than here.
if ARTI_CLIENT.lock().unwrap().is_some() {
return Ok(Some(String::new()));
}
Ok(match data_dir.try_to_string(env) {
Ok(s) => Some(s),
Err(e) => {
log_error!("Failed to convert data_dir: {:?}", e);
None
}
})
})
.resolve::<LogErrorAndDefault>();
// Already initialized — skip
if ARTI_CLIENT.lock().unwrap().is_some() {
@@ -127,12 +168,9 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
return 0;
}
let data_dir_str: String = match env.get_string(&data_dir) {
Ok(s) => s.into(),
Err(e) => {
log_error!("Failed to convert data_dir: {:?}", e);
return -1;
}
let data_dir_str: String = match data_dir_str {
Some(s) => s,
None => return -1,
};
log_info!("Initializing Arti with data directory: {}", data_dir_str);
@@ -220,7 +258,9 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
.create_unbootstrapped_async()
.await
{
Ok(c) => Arc::new(c),
// Arti 2.4.0 made every TorClient constructor return an Arc<TorClient>
// (TorClient itself is no longer Clone), so there is nothing to wrap here.
Ok(c) => c,
Err(e) => {
log_error!("Failed to create Tor client: {:?}", e);
return -3;
@@ -267,7 +307,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
/// Can be called multiple times — stops any existing listener first.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy(
_env: JNIEnv,
_env: EnvUnowned,
_class: JClass,
port: jint,
) -> jint {
@@ -494,7 +534,7 @@ async fn handle_socks_connection(
/// Stop the SOCKS proxy listener. The TorClient stays alive.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy(
_env: JNIEnv,
_env: EnvUnowned,
_class: JClass,
) -> jint {
log_info!("Stopping SOCKS proxy...");
@@ -532,7 +572,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro
/// measurable signal for a guess.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_bootstrapProgressPermille(
_env: JNIEnv,
_env: EnvUnowned,
_class: JClass,
) -> jint {
match ARTI_CLIENT.lock().unwrap().as_ref() {
@@ -550,7 +590,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_bootstrapPro
/// leaving the UI wrong and the exit-rotation self-heal disabled.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_isBootstrapped(
_env: JNIEnv,
_env: EnvUnowned,
_class: JClass,
) -> jint {
match ARTI_CLIENT.lock().unwrap().as_ref() {
@@ -573,7 +613,7 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_isBootstrapp
/// (and re-bootstrap).
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy(
_env: JNIEnv,
_env: EnvUnowned,
_class: JClass,
) -> jint {
log_info!("Destroying Arti client");