Compare commits

...
4 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 8d75a50df9 Merge pull request #4120 from vitorpamplona/claude/sweet-fermat-10uvw5
Update Arti to 2.6.0 and jni to 0.22, pin and enforce the NDK revision
2026-09-13 17:17:52 -04:00
Claude fde0b7f689 fix: harden the Arti build gates and drop initialize()'s sentinel
Follow-up to the toolchain update, from an audit of that diff.

Build script:
- The NDK pin rejected machines that have the pinned revision installed.
  An exported ANDROID_NDK_HOME or ANDROID_NDK_ROOT short-circuited the
  search and then failed the revision check, and GitHub runners export both
  at their own bundled NDK. Every candidate is now checked against its own
  source.properties and a mismatch moves on, so the build fails only when
  the pinned revision is genuinely absent, listing what it found instead.
- verify_jni_symbols printed missing exports and exited 0, so a library that
  would throw UnsatisfiedLinkError on every call could ship. It now fails the
  build, and checks only the ABIs this run built.
- Both post-build checks now use the pinned NDK's own llvm-readelf and
  llvm-nm. The stamp check silently skipped on macOS, which has no readelf,
  and Apple's nm cannot read ELF at all, so the symbol check would have
  reported every symbol missing there.
- The stamp check read its note through `readelf | grep -q`, the same
  SIGPIPE-plus-pipefail shape this branch removed from the symbol check.
- $HOME is expanded with a default, so `set -u` no longer aborts before the
  "NDK not found" message in an environment without HOME.

verify-reproducible.sh hashed every .so under jniLibs, so --release, which
rebuilds arm64 only, hashed the untouched x86_64 library identically in both
runs and reported the whole tree reproducible and matching the commit. It now
hashes and diffs only the ABIs the run builds, and prints which those are.

lib.rs:
- initialize() signalled "already initialized" out of the JNI closure as an
  empty string, re-tested after it. A destroy() landing in between would let
  the empty string through as the data directory, which resolves to relative
  state/ and cache/ paths against the process working directory. The check
  now reads the whole Option outside the closure and no sentinel exists.
- Corrected the comments claiming the error policy keeps a panic from
  crossing extern "C". It does not: the policy's panic arm runs through
  catch_unwind, which catches nothing under this crate's panic = "abort"
  profile. The Err arm, which is what the code relies on, is unaffected.

README: the troubleshooting section still told readers to install cargo-ndk
unpinned and to export ANDROID_NDK_HOME at an arbitrary revision, which was
the exact way to trip the old gate.

Verified: two clean builds byte-for-byte identical, both ABIs stamped r30,
JNI exports present, 16 KiB alignment kept. JVM tier-3 smoke test green, and
a scratch harness drove getVersion, setLogCallback, initialize, a second
initialize on a live client (the reuse path the sentinel used to carry) and
destroy over real JNI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
2026-09-13 20:46:31 +00:00
Claude e32cadc250 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
2026-09-13 18:51:50 +00:00
Claude b52f5651de build: pin the Arti NDK and rebuild libarti_android.so on r27d
The committed libraries were built with NDK r25b (25.1.8937393) while the
build docs told everyone to install r27. Nothing pinned the NDK, so
build-arti.sh took the first directory matching ~/Android/Sdk/ndk/*/. The
NDK supplies the clang that compiles Arti's C dependencies (ring, zstd-sys,
libsqlite3-sys) and the lld that links the cdylib, so its revision is baked
into the output bytes exactly like rustc's is. The reproducible-build
promise therefore only held by accident of which NDK a verifier happened to
have installed.

- Pin the revision in ANDROID_NDK_VERSION (27.3.13750724, r27d) and resolve
  it by name. A different revision now fails the build with the sdkmanager
  line that fixes it, instead of silently producing unverifiable bytes.
- Record the verified cargo-ndk release in CARGO_NDK_VERSION. Warning only:
  it wraps the NDK rather than generating code.
- Re-read .note.android.ident after each build, so the output has to carry
  the pinned NDK's stamp to pass.
- Rebuild both ABIs on r27d (clang 18.0.4, lld 18.0.4, rustc 1.94.1).
  verify-reproducible.sh: two clean builds byte-for-byte identical, all 8
  JNI symbols exported, 16 KiB LOAD alignment kept, same libc/libm/libdl
  dependency set as before.
- Fix verify_jni_symbols reporting every exported symbol as missing: piping
  nm into `grep -q` per symbol lets grep exit first, nm dies of SIGPIPE, and
  `set -o pipefail` fails the pipeline. Pre-existing, reproduces on the old
  binary too.
- Docs: the 16 KiB page alignment comes from rustc's Android target spec,
  not from "NDK 25+".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
2026-09-13 16:43:33 +00:00
14 changed files with 1198 additions and 710 deletions
+3 -2
View File
@@ -105,8 +105,9 @@ and each has its own guide:
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
are **not** required to build Amethyst from the committed sources.
Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
---
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
30.0.16248370
+1 -1
View File
@@ -1 +1 @@
arti-v2.3.0
arti-v2.6.0
+1
View File
@@ -0,0 +1 @@
4.1.2
+821 -597
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "arti-android"
version = "2.3.0"
version = "2.6.0"
edition = "2021"
[lib]
@@ -9,20 +9,21 @@ 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).
# since arti-v2.3.0 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 (Arti's default since 2.3.0), which `default-features = false`
# keeps out of the build entirely.
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"
+70 -15
View File
@@ -9,9 +9,9 @@ JNI wrapper built directly from Arti source.
| | Guardian Project AAR | Custom build |
|---|---|---|
| **Size** | ~140MB | ~11MB |
| **16KB pages** | No | Yes (NDK 25+) |
| **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
@@ -22,15 +22,34 @@ rebuild if you want to verify binaries, update the Arti version, or modify the J
The shipped `.so` is **built to be reproducible** so anyone — F-Droid, Zapstore,
or an independent auditor — can rebuild it from this tag and confirm the
committed binary wasn't tampered with. **Four** things have to be fixed:
committed binary wasn't tampered with. **Five** things have to be fixed:
| Source of non-determinism | Pinned by |
|---|---|
| `rustc` / cargo version | [`rust-toolchain.toml`](rust-toolchain.toml) (rustup auto-installs it) |
| **Android NDK revision** | [`ANDROID_NDK_VERSION`](ANDROID_NDK_VERSION); `build-arti.sh` refuses to build with any other revision |
| transitive dependency versions | committed [`Cargo.lock`](Cargo.lock); builds run `cargo --locked` |
| absolute paths *embedded* in the binary | `--remap-path-prefix` in [`repro-env.sh`](repro-env.sh) |
| codegen/link **ordering** keyed on the real build path | **canonical build path** (`build-arti.sh` builds in `/tmp/amethyst-arti-build`) |
> **Why the NDK is pinned.** It is not just an SDK detail: the NDK supplies the
> clang that compiles Arti's C dependencies (`ring`, `zstd-sys`,
> `libsqlite3-sys`) and the `lld` that links the final `cdylib`, both of which
> stamp themselves into the binary's `.comment` section next to `rustc`'s own
> version. Swapping the NDK changes the bytes exactly like swapping `rustc`
> would. Before this was pinned the build picked the first directory matching
> `~/Android/Sdk/ndk/*/`, so the committed libraries were produced by r25b while
> this file told everyone to install r27 — two verifiers could both follow the
> README and get different, equally "correct" results. `build-arti.sh` now
> reads each candidate's `source.properties` and keeps looking until it finds
> the pinned revision, then re-checks the `.note.android.ident` stamp of every
> `.so` it produced.
>
> [`CARGO_NDK_VERSION`](CARGO_NDK_VERSION) records the `cargo-ndk` release the
> pinned output was verified with. `cargo-ndk` only wraps the NDK, so a mismatch
> is a warning rather than an error — but it is the next thing to check if your
> rebuild does not match.
`repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0`
and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized
release profile in `Cargo.toml` (`lto`, `codegen-units = 1`, `strip`,
@@ -74,20 +93,27 @@ repo is checked out.
rustup target add aarch64-linux-android x86_64-linux-android
```
3. **cargo-ndk**
3. **cargo-ndk** — the release the pinned output was verified with:
```bash
cargo install cargo-ndk
cargo install cargo-ndk --version "$(cat CARGO_NDK_VERSION)" --locked
```
4. **Android NDK 25+** (required for 16KB page size support)
4. **Android NDK** — the exact revision in [`ANDROID_NDK_VERSION`](ANDROID_NDK_VERSION)
(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)
# Or via command line:
sdkmanager "ndk;27.0.12077973"
# Set environment variable
export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/27.0.12077973"
sdkmanager "ndk;$(cat ANDROID_NDK_VERSION)"
```
`build-arti.sh` finds it automatically under `$ANDROID_HOME/ndk/`,
`$ANDROID_SDK_ROOT/ndk/`, `~/Android/Sdk/ndk/`, `~/Library/Android/sdk/ndk/`
or `/usr/local/lib/android/sdk/ndk/`. `ANDROID_NDK_HOME` and
`ANDROID_NDK_ROOT` are tried first when set, but they are only hints: every
candidate is checked against its own `source.properties`, and one at the
wrong revision is reported and skipped rather than failing the build. CI
images (GitHub runners among them) export both at a bundled NDK that is not
ours.
## Building
@@ -130,14 +156,38 @@ Google Play requires 16KB page-aligned native libraries. Verify with:
readelf -l amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD
```
The first LOAD segment alignment should be `0x4000` (16384 bytes).
The first LOAD segment alignment should be `0x4000` (16384 bytes). This comes
from rustc's Android target spec (`max-page-size=16384`), not from the NDK, so
it holds for every NDK revision we could build with.
## Checking which toolchain built a `.so`
The shipped binaries say so themselves — useful when a rebuild does not match, or
when auditing a `.so` you did not build:
```bash
# NDK release name + build number (the last component of the pinned revision)
readelf -p .note.android.ident amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so
# clang / lld (from the NDK) and rustc versions
readelf -p .comment amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so
```
For the pinned toolchain the first command prints `r30` and build `16248370`.
The second prints the `rustc` version from `rust-toolchain.toml`, the NDK's
`clang` and `LLD`, and a second, different clang string that comes from the
prebuilt runtime objects the NDK links in — two clang lines there is normal.
`build-arti.sh` runs the first check itself after every build, using the NDK's
own `llvm-readelf` so it works the same on macOS.
## Directory structure
```
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)
├── Cargo.toml # Rust dependencies and build profile
├── Cargo.lock # Pinned transitive dependency versions (reproducibility)
@@ -243,13 +293,18 @@ panic = "abort" # No unwinding (smaller binary)
### `cargo-ndk` not found
```bash
cargo install cargo-ndk
cargo install cargo-ndk --version "$(cat CARGO_NDK_VERSION)" --locked
```
### NDK not found
### NDK not found, or "wrong revision"
Install the pinned revision — the build refuses any other, and the error lists
every directory it looked at and what it found there:
```bash
export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/<version>"
sdkmanager "ndk;$(cat ANDROID_NDK_VERSION)"
```
Point `ANDROID_NDK_HOME` at it only if it lives outside the standard SDK
layouts; an `ANDROID_NDK_HOME` left over from another project is skipped, not
fatal.
### Rust targets not installed
```bash
+168 -26
View File
@@ -6,7 +6,8 @@
# - Rust toolchain: rustup, cargo
# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android
# - cargo-ndk: cargo install cargo-ndk
# - Android NDK 25+ (for 16KB page size support)
# - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION
# (sdkmanager "ndk;<revision>") — see README.md -> "Reproducible builds"
#
# Usage:
# ./build-arti.sh # Build for all targets (arm64 + x86_64)
@@ -26,6 +27,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ARTI_VERSION=$(cat "$SCRIPT_DIR/ARTI_VERSION" | tr -d '[:space:]')
# Reproducibility: the NDK ships the clang that compiles Arti's C dependencies
# (ring, zstd-sys, libsqlite3-sys) and the lld that links the whole cdylib, so
# its revision is baked into the output bytes exactly like rustc's is — both
# land in the .comment section of the shipped .so. Pin it here and refuse to
# build with anything else; the old glob over ~/Android/Sdk/ndk/*/ silently
# picked up whatever happened to be installed first.
NDK_VERSION=$(cat "$SCRIPT_DIR/ANDROID_NDK_VERSION" | tr -d '[:space:]')
# The NDK build number (last component of the revision) is what the linker
# stamps into .note.android.ident, so it is how we verify the output afterwards.
NDK_BUILD_NUMBER="${NDK_VERSION##*.}"
# cargo-ndk only wraps the NDK (it sets CC/AR/linker and the --platform flags),
# but those flags reach the linker, so record the version we verified with and
# warn when it differs. Not a hard error: unlike the NDK itself, it has no
# proven effect on the bytes.
CARGO_NDK_VERSION=$(cat "$SCRIPT_DIR/CARGO_NDK_VERSION" | tr -d '[:space:]')
# Reproducibility: rustc bakes the *real* (un-remapped) absolute paths of the
# build artifacts into its codegen/link ORDERING, so --remap-path-prefix alone
# is not enough — the .so only reproduces byte-for-byte when the compile happens
@@ -68,33 +85,89 @@ print_info() { echo -e "${YELLOW}→ $1${NC}"; }
# Prerequisites
# ============================================================================
# Pkg.Revision of an NDK install, or empty if the directory is not one.
ndk_revision() {
sed -n 's/^Pkg\.Revision *= *//p' "$1/source.properties" 2>/dev/null | tr -d '[:space:]' || true
}
# Path to an ELF tool, preferring the pinned NDK's own llvm-* copy. The NDK
# ships them on every platform, which keeps the post-build checks working on
# macOS: there is no readelf in the Xcode command line tools, and Apple's nm
# cannot read ELF at all, so the checks would otherwise skip or report every
# symbol missing on exactly the machines most likely to have the wrong NDK.
ndk_tool() {
local name="$1" candidate
for candidate in "${ANDROID_NDK_HOME:-}"/toolchains/llvm/prebuilt/*/bin/"llvm-$name"; do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v "$name" 2>/dev/null && return 0
command -v "g$name" 2>/dev/null && return 0
return 1
}
check_prerequisites() {
print_header "Checking prerequisites"
command -v git >/dev/null 2>&1 || { print_error "git not found"; exit 1; }
command -v rustup >/dev/null 2>&1 || { print_error "rustup not found"; exit 1; }
command -v cargo >/dev/null 2>&1 || { print_error "cargo not found"; exit 1; }
command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk"; exit 1; }
command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk --version $CARGO_NDK_VERSION --locked"; exit 1; }
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
# Try common locations
for candidate in \
"$HOME/Android/Sdk/ndk/"*/ \
"$HOME/Library/Android/sdk/ndk/"*/ \
"/usr/local/lib/android/sdk/ndk/"*/; do
if [ -d "$candidate" ]; then
export ANDROID_NDK_HOME="${candidate%/}"
break
fi
done
local found_cargo_ndk
found_cargo_ndk="$(cargo ndk --version 2>/dev/null | awk '{print $2}' || true)"
if [ "$found_cargo_ndk" != "$CARGO_NDK_VERSION" ]; then
print_info "cargo-ndk ${found_cargo_ndk:-unknown} != pinned $CARGO_NDK_VERSION — if the"
print_info " output does not match the committed .so, try: cargo install cargo-ndk --version $CARGO_NDK_VERSION --locked"
else
print_success "cargo-ndk: $CARGO_NDK_VERSION"
fi
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
print_error "ANDROID_NDK_HOME not set and NDK not found in common locations"
# Find the pinned revision wherever it lives, checking each candidate's own
# source.properties and moving on when it does not match. An exported
# ANDROID_NDK_HOME / ANDROID_NDK_ROOT is only a hint: CI images (GitHub
# runners export both) and IDE installs routinely point them at a bundled
# NDK that is not ours, and failing outright there would reject a machine
# that has the pinned revision installed right next to it. No wildcard
# anywhere: picking "some NDK" is what let the committed binaries be built
# with r25b while the docs asked for r27.
local candidate revision found_ndk="" rejected=""
for candidate in \
"${ANDROID_NDK_HOME:-}" \
"${ANDROID_NDK_ROOT:-}" \
"${ANDROID_HOME:-}/ndk/$NDK_VERSION" \
"${ANDROID_SDK_ROOT:-}/ndk/$NDK_VERSION" \
"${HOME:-}/Android/Sdk/ndk/$NDK_VERSION" \
"${HOME:-}/Library/Android/sdk/ndk/$NDK_VERSION" \
"/usr/local/lib/android/sdk/ndk/$NDK_VERSION"; do
[ -n "$candidate" ] || continue
[ -d "$candidate" ] || continue
revision="$(ndk_revision "$candidate")"
if [ "$revision" = "$NDK_VERSION" ]; then
found_ndk="${candidate%/}"
break
fi
rejected="${rejected} ${candidate%/} is ${revision:-not an NDK}"$'\n'
done
if [ -z "$found_ndk" ]; then
print_error "Android NDK $NDK_VERSION not found"
echo " It is pinned because another revision produces a .so that does not"
echo " match the committed one (tools/arti-build/ANDROID_NDK_VERSION)."
if [ -n "$rejected" ]; then
echo " Looked at, wrong revision:"
printf '%s' "$rejected"
fi
echo " Install it: sdkmanager \"ndk;$NDK_VERSION\""
echo " Or point ANDROID_NDK_HOME at an existing $NDK_VERSION install."
exit 1
fi
print_success "NDK: $ANDROID_NDK_HOME"
export ANDROID_NDK_HOME="$found_ndk"
print_success "NDK: $ANDROID_NDK_HOME ($NDK_VERSION)"
for target in "${TARGETS[@]}"; do
if ! rustup target list --installed | grep -q "$target"; then
@@ -180,17 +253,22 @@ PATCH
# Build
# ============================================================================
# Android ABI directory (as laid out under jniLibs/) for a Rust target triple.
abi_dir_for() {
case "$1" in
aarch64-linux-android) echo "arm64-v8a" ;;
x86_64-linux-android) echo "x86_64" ;;
armv7-linux-androideabi) echo "armeabi-v7a" ;;
i686-linux-android) echo "x86" ;;
esac
}
build_for_target() {
local target="$1"
print_header "Building for $target"
local arch_dir
case "$target" in
aarch64-linux-android) arch_dir="arm64-v8a" ;;
x86_64-linux-android) arch_dir="x86_64" ;;
armv7-linux-androideabi) arch_dir="armeabi-v7a" ;;
i686-linux-android) arch_dir="x86" ;;
esac
arch_dir="$(abi_dir_for "$target")"
local out_dir="$OUTPUT_DIR/$arch_dir"
mkdir -p "$out_dir"
@@ -229,15 +307,32 @@ verify_jni_symbols() {
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy"
)
for arch_dir in "$OUTPUT_DIR"/*/; do
local lib="$arch_dir$LIB_NAME"
local nm_bin
nm_bin="$(ndk_tool nm || true)"
if [ -z "$nm_bin" ]; then
print_error "no nm found (looked in the NDK and on PATH) — cannot verify the JNI exports"
exit 1
fi
local failed=0
for target in "${TARGETS[@]}"; do
local arch
arch="$(abi_dir_for "$target")"
local lib="$OUTPUT_DIR/$arch/$LIB_NAME"
[ -f "$lib" ] || continue
local arch=$(basename "$arch_dir")
local missing=0
# Read the dynamic symbol table once, into a variable. Piping nm into
# `grep -q` per symbol looks equivalent but is not: grep exits on the
# first match, nm dies of SIGPIPE (141), and `set -o pipefail` then
# reports the pipeline as failed — so every symbol that IS exported gets
# reported as missing. (Reproducible on any build, old or new.)
local syms
syms="$("$nm_bin" -D "$lib" 2>/dev/null || true)"
for sym in "${expected_symbols[@]}"; do
if ! nm -D "$lib" 2>/dev/null | grep -q "$sym"; then
if [[ "$syms" != *"$sym"* ]]; then
print_error "$arch: Missing symbol $sym"
missing=1
fi
@@ -245,6 +340,52 @@ verify_jni_symbols() {
if [ "$missing" -eq 0 ]; then
print_success "$arch: All JNI symbols present"
else
failed=1
fi
done
# Hard failure: a library missing these exports still loads, and then every
# ArtiNative call throws UnsatisfiedLinkError at runtime instead.
if [ "$failed" -ne 0 ]; then
print_error "JNI exports missing — refusing to leave this .so in jniLibs"
exit 1
fi
}
verify_ndk_stamp() {
print_header "Verifying NDK stamp"
local readelf_bin
readelf_bin="$(ndk_tool readelf || true)"
if [ -z "$readelf_bin" ]; then
print_error "no readelf found (looked in the NDK and on PATH) — cannot verify the NDK stamp"
exit 1
fi
# Every NDK-linked shared object carries .note.android.ident, which records
# the target API level, the NDK release name (e.g. r27d) and the NDK build
# number. Reading it back proves which toolchain actually produced the
# binary, independently of what the environment claimed — this is how the
# committed r25b libraries were identified in the first place.
for target in "${TARGETS[@]}"; do
local arch
arch="$(abi_dir_for "$target")"
local lib="$OUTPUT_DIR/$arch/$LIB_NAME"
[ -f "$lib" ] || continue
# Read the note once into a variable: `readelf | grep -q` would let grep
# exit first, kill readelf with SIGPIPE, and fail the pipeline under
# `set -o pipefail` — the same trap that made the symbol check above
# report every exported symbol as missing.
local note
note="$("$readelf_bin" -p .note.android.ident "$lib" 2>/dev/null || true)"
if grep -qw "$NDK_BUILD_NUMBER" <<< "$note"; then
print_success "$arch: built by NDK $NDK_VERSION"
else
print_error "$arch: not stamped with NDK build $NDK_BUILD_NUMBER — wrong toolchain?"
printf '%s\n' "$note"
exit 1
fi
done
}
@@ -280,6 +421,7 @@ main() {
done
verify_jni_symbols
verify_ndk_stamp
print_header "Build complete"
echo ""
+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
+99 -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,92 @@ 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
// `Err` to the policy — here a Java RuntimeException plus a null return —
// rather than losing it.
//
// Only the `Err` half is live: the policy's panic half runs through
// `catch_unwind`, which catches nothing under this crate's
// `panic = "abort"` release profile, so a panic in here still takes the
// process down exactly as it did before the migration.
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`.
//
// `None` carries a failed read, because it is `Option::default()` and so is
// also what the policy yields for an `Err`. Both end at the same `-1` the
// old `Err` arm returned. Resolving to `jint` directly would have defaulted
// to `0`, the value this API reports as success.
//
// The already-initialized check deliberately stays *outside* the closure,
// against the whole `Option`: threading it through as a sentinel value
// would leave that sentinel to be re-tested after the closure, and a
// concurrent destroy() landing in between would let it through as the data
// directory.
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);
}
}
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 +173,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 +263,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 +312,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 +539,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 +577,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 +595,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 +618,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");
+25 -7
View File
@@ -12,7 +12,9 @@
# ./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64)
# ./verify-reproducible.sh --release # arm64-v8a only (faster)
#
# Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, Android NDK).
# Prerequisites are the same as build-arti.sh (rustup, cargo-ndk, and the exact
# Android NDK revision pinned in ANDROID_NDK_VERSION — a different revision is
# refused, because it would change the output bytes).
# Exit 0 = reproducible, exit 1 = builds differ.
set -euo pipefail
@@ -26,15 +28,25 @@ sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi
}
# sha256 of every built .so, keyed by ABI dir (relative paths → stable keys).
# `find | sort` (plain text sort) is portable across GNU and BSD userlands.
# Only the ABIs this run actually rebuilds. Hashing everything under jniLibs/
# (what `find` used to do) made `--release` look like it had verified the
# x86_64 library: that build never touches it, so the untouched file hashed
# identically in both runs and the script reported the whole tree reproducible
# and matching the commit.
ABIS="arm64-v8a x86_64"
for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do
[ "$arg" = "--release" ] && ABIS="arm64-v8a"
done
# sha256 of each built .so, keyed by ABI dir (relative paths → stable keys).
hashes() {
( cd "$JNILIBS" && find . -name libarti_android.so | sort | while IFS= read -r f; do
sha256 "$f"
( cd "$JNILIBS" && for abi in $ABIS; do
[ -f "$abi/libarti_android.so" ] && sha256 "$abi/libarti_android.so"
done )
}
echo "### Reproducibility check for libarti_android.so"
echo "### ABIs: $ABIS"
echo "### Canonical build path: ${ARTI_REPRO_DIR:-/tmp/amethyst-arti-build}"
echo
@@ -59,11 +71,17 @@ fi
# Informational: is the binary committed in git already the reproducible one?
echo
echo "### vs. the committed binaries:"
if git -C "$PROJECT_ROOT" diff --quiet -- amethyst/src/main/jniLibs/; then
BUILT_PATHS=""
for abi in $ABIS; do
BUILT_PATHS="$BUILT_PATHS amethyst/src/main/jniLibs/$abi/libarti_android.so"
done
# shellcheck disable=SC2086 # BUILT_PATHS is a deliberate multi-path list
if git -C "$PROJECT_ROOT" diff --quiet -- $BUILT_PATHS; then
echo "✓ The reproducible build matches what's committed — the shipped .so is verifiable as-is."
else
echo "⚠ The reproducible build differs from the committed .so (e.g. the committed one"
echo " predates this toolchain). Commit the rebuilt binaries so the shipped artifact"
echo " is itself a reproducible build:"
echo " git -C \"$PROJECT_ROOT\" add amethyst/src/main/jniLibs && git commit"
echo " git -C \"$PROJECT_ROOT\" add$BUILT_PATHS && git commit"
fi