mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Vendor a reproducible arm64 Arti library.
This commit is contained in:
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
.arti-source/
|
||||
target/
|
||||
@@ -0,0 +1 @@
|
||||
30.0.16248370
|
||||
@@ -0,0 +1 @@
|
||||
arti-v2.6.0
|
||||
@@ -0,0 +1 @@
|
||||
4.1.2
|
||||
Generated
+5624
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "arti-android"
|
||||
version = "2.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
arti-client = { version = "0.46", default-features = false, features = [
|
||||
"tokio",
|
||||
"rustls",
|
||||
"compression",
|
||||
"onion-service-client",
|
||||
"static-sqlite",
|
||||
] }
|
||||
tor-rtcompat = { version = "0.46", default-features = false, features = ["tokio", "rustls"] }
|
||||
# Direct dep on rustls so we can install the `ring` crypto provider ourselves —
|
||||
# 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.22"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] }
|
||||
anyhow = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Arti Android Build Tools
|
||||
|
||||
Custom-built [Arti](https://gitlab.torproject.org/tpo/core/arti) native libraries
|
||||
for Zapstore. Adapted from [Amethyst's `tools/arti-build`](https://github.com/vitorpamplona/amethyst/tree/main/tools/arti-build):
|
||||
same size-optimized wrapper, JNI names retargeted to `dev.zapstore.app.transport.ArtiNative`.
|
||||
|
||||
The app ships **arm64-v8a only**. `make vendor` from the repo root runs
|
||||
`./build-arti.sh` and writes:
|
||||
|
||||
```
|
||||
src/main/jniLibs/arm64-v8a/libarti_android.so
|
||||
```
|
||||
|
||||
Commit that `.so` after a successful vendor. Rebuild when bumping
|
||||
`ARTI_VERSION`, changing `src/lib.rs`, or verifying reproducibility.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. rustup (the toolchain in `rust-toolchain.toml` is installed automatically)
|
||||
2. `cargo install cargo-ndk --version "$(cat CARGO_NDK_VERSION)" --locked`
|
||||
3. The exact NDK in `ANDROID_NDK_VERSION`: `sdkmanager "ndk;$(cat ANDROID_NDK_VERSION)"`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
./build-arti.sh # arm64-v8a
|
||||
./build-arti.sh --clean # wipe the Arti clone and rebuild
|
||||
./verify-reproducible.sh # two clean builds, then diff
|
||||
```
|
||||
|
||||
The compile happens at `/tmp/zapstore-arti-build` so the output is
|
||||
path-independent. Override with `ARTI_REPRO_DIR` only if you do not need
|
||||
to match committed bytes.
|
||||
Executable
+452
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build Arti native libraries for Android from source.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust toolchain: rustup, cargo
|
||||
# - Android target: rustup target add aarch64-linux-android
|
||||
# - cargo-ndk: cargo install cargo-ndk
|
||||
# - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION
|
||||
# (sdkmanager "ndk;<revision>") — see README.md -> "Reproducible builds"
|
||||
#
|
||||
# Usage:
|
||||
# ./build-arti.sh # arm64-v8a (the only shipped ABI)
|
||||
# ./build-arti.sh --clean # Clean and rebuild
|
||||
# ./build-arti.sh --print-abis # print the jniLibs ABI dir, then exit
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
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
|
||||
# at a fixed path. Everyone who needs to reproduce the shipped binary (us,
|
||||
# F-Droid, an independent verifier) must therefore build at this same canonical
|
||||
# location. Overriding ARTI_REPRO_DIR changes the output bytes; only do it if
|
||||
# you don't care about matching the published .so.
|
||||
ARTI_BUILD_ROOT="${ARTI_REPRO_DIR:-/tmp/zapstore-arti-build}"
|
||||
ARTI_SOURCE_DIR="$ARTI_BUILD_ROOT/.arti-source"
|
||||
OUTPUT_DIR="$PROJECT_ROOT/src/main/jniLibs"
|
||||
LIB_NAME="libarti_android.so"
|
||||
MIN_SDK_VERSION=29
|
||||
|
||||
# The APK ships arm64-v8a only (build.gradle.kts -> ndk.abiFilters).
|
||||
TARGETS=("aarch64-linux-android")
|
||||
CLEAN=false
|
||||
REGEN_LOCK=false
|
||||
PRINT_ABIS=false
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--print-abis) PRINT_ABIS=true ;;
|
||||
--clean) CLEAN=true ;;
|
||||
# Refresh the committed Cargo.lock from the pinned Arti tag, then exit
|
||||
# (no compile — needs only git + cargo, not the NDK). Use after bumping
|
||||
# ARTI_VERSION / Cargo.toml; the normal build is --locked and will fail
|
||||
# until the lock is regenerated and committed.
|
||||
--regen-lock) REGEN_LOCK=true; CLEAN=true ;;
|
||||
--help) echo "Usage: $0 [--clean] [--regen-lock] [--print-abis] [--help]"; exit 0 ;;
|
||||
--release|--target=*)
|
||||
echo "This project ships arm64-v8a only; extra ABI flags are not accepted." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; }
|
||||
print_success() { echo -e "${GREEN}✓ $1${NC}"; }
|
||||
print_error() { echo -e "${RED}✗ $1${NC}"; }
|
||||
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 --version $CARGO_NDK_VERSION --locked"; exit 1; }
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
print_info "Adding Rust target: $target"
|
||||
rustup target add "$target"
|
||||
fi
|
||||
print_success "Target: $target"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Source Management
|
||||
# ============================================================================
|
||||
|
||||
clone_or_update_arti() {
|
||||
print_header "Setting up Arti source ($ARTI_VERSION)"
|
||||
|
||||
if [ "$CLEAN" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then
|
||||
print_info "Cleaning existing source"
|
||||
rm -rf "$ARTI_SOURCE_DIR"
|
||||
fi
|
||||
|
||||
mkdir -p "$ARTI_BUILD_ROOT"
|
||||
print_info "Canonical build path: $ARTI_BUILD_ROOT (set ARTI_REPRO_DIR to override)"
|
||||
|
||||
if [ ! -d "$ARTI_SOURCE_DIR" ]; then
|
||||
print_info "Cloning Arti repository..."
|
||||
git clone --depth 1 --branch "$ARTI_VERSION" \
|
||||
https://gitlab.torproject.org/tpo/core/arti.git \
|
||||
"$ARTI_SOURCE_DIR"
|
||||
else
|
||||
print_info "Updating existing clone to $ARTI_VERSION"
|
||||
cd "$ARTI_SOURCE_DIR"
|
||||
git fetch --depth 1 origin tag "$ARTI_VERSION"
|
||||
git checkout "$ARTI_VERSION"
|
||||
cd "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
print_success "Arti source ready at $ARTI_SOURCE_DIR"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Wrapper Setup
|
||||
# ============================================================================
|
||||
|
||||
setup_wrapper() {
|
||||
print_header "Setting up JNI wrapper"
|
||||
|
||||
local wrapper_dir="$ARTI_SOURCE_DIR/arti-android-wrapper"
|
||||
mkdir -p "$wrapper_dir/src"
|
||||
|
||||
cp "$SCRIPT_DIR/Cargo.toml" "$wrapper_dir/Cargo.toml"
|
||||
cp "$SCRIPT_DIR/src/lib.rs" "$wrapper_dir/src/lib.rs"
|
||||
|
||||
# Reproducibility: build against the committed lockfile so transitive
|
||||
# dependency versions are identical for everyone. `cargo --locked` (in
|
||||
# build_for_target) fails loudly if this lock is missing or stale rather than
|
||||
# silently re-resolving. (Missing is only expected during --regen-lock.)
|
||||
if [ -f "$SCRIPT_DIR/Cargo.lock" ]; then
|
||||
cp "$SCRIPT_DIR/Cargo.lock" "$wrapper_dir/Cargo.lock"
|
||||
print_success "Pinned dependencies from committed Cargo.lock"
|
||||
else
|
||||
print_info "No committed Cargo.lock yet — run with --regen-lock to create it"
|
||||
fi
|
||||
|
||||
# Patch Cargo.toml to use local arti-client from the source tree
|
||||
# instead of pulling from crates.io
|
||||
cd "$wrapper_dir"
|
||||
|
||||
# Add path overrides for the local arti source
|
||||
cat >> Cargo.toml << 'PATCH'
|
||||
|
||||
[patch.crates-io]
|
||||
arti-client = { path = "../crates/arti-client" }
|
||||
tor-rtcompat = { path = "../crates/tor-rtcompat" }
|
||||
PATCH
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
print_success "JNI wrapper configured"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Build
|
||||
# ============================================================================
|
||||
|
||||
# Android ABI directory (as laid out under jniLibs/) for a Rust target triple.
|
||||
# Unknown triples are fatal rather than empty: an empty answer would make the
|
||||
# caller write "$OUTPUT_DIR/" — i.e. drop the .so loose in jniLibs/, where no ABI
|
||||
# picks it up — and both verification loops would skip it without a word.
|
||||
abi_dir_for() {
|
||||
case "$1" in
|
||||
aarch64-linux-android) echo "arm64-v8a" ;;
|
||||
*) print_error "Unsupported Rust target '$1' — only aarch64-linux-android / arm64-v8a is shipped" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_for_target() {
|
||||
local target="$1"
|
||||
print_header "Building for $target"
|
||||
|
||||
local arch_dir
|
||||
arch_dir="$(abi_dir_for "$target")"
|
||||
|
||||
local out_dir="$OUTPUT_DIR/$arch_dir"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
cargo ndk \
|
||||
-t "$target" \
|
||||
--platform "$MIN_SDK_VERSION" \
|
||||
-o "$OUTPUT_DIR" \
|
||||
build --release --locked \
|
||||
--manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml"
|
||||
|
||||
if [ -f "$out_dir/$LIB_NAME" ]; then
|
||||
local size=$(du -h "$out_dir/$LIB_NAME" | cut -f1)
|
||||
print_success "Built $arch_dir/$LIB_NAME ($size)"
|
||||
else
|
||||
print_error "Build failed — $out_dir/$LIB_NAME not found"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Verification
|
||||
# ============================================================================
|
||||
|
||||
verify_jni_symbols() {
|
||||
print_header "Verifying JNI symbols"
|
||||
|
||||
local expected_symbols=(
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_getVersion"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_setLogCallback"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_initialize"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_startSocksProxy"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_stopSocksProxy"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_isBootstrapped"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_bootstrapProgressPermille"
|
||||
"Java_dev_zapstore_app_transport_ArtiNative_destroy"
|
||||
)
|
||||
|
||||
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 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 [[ "$syms" != *"$sym"* ]]; then
|
||||
print_error "$arch: Missing symbol $sym"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
# Answer --print-abis before any other output, so the caller gets exactly the
|
||||
# ABI directory names on stdout and nothing else. Runs here rather than in the
|
||||
# argument loop because abi_dir_for is not defined yet at that point.
|
||||
if [ "$PRINT_ABIS" = true ]; then
|
||||
for target in "${TARGETS[@]}"; do
|
||||
abi_dir_for "$target"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}"
|
||||
|
||||
# --regen-lock only needs git + cargo, not the NDK/cargo-ndk toolchain.
|
||||
[ "$REGEN_LOCK" = true ] || check_prerequisites
|
||||
clone_or_update_arti
|
||||
setup_wrapper
|
||||
|
||||
if [ "$REGEN_LOCK" = true ]; then
|
||||
print_header "Regenerating Cargo.lock"
|
||||
local manifest="$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml"
|
||||
cargo generate-lockfile --manifest-path "$manifest"
|
||||
cp "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.lock" "$SCRIPT_DIR/Cargo.lock"
|
||||
print_success "Updated $SCRIPT_DIR/Cargo.lock — commit it, then re-run the build."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Deterministic build env (needs ARTI_SOURCE_DIR cloned above for SOURCE_DATE_EPOCH).
|
||||
# shellcheck source=repro-env.sh
|
||||
source "$SCRIPT_DIR/repro-env.sh"
|
||||
print_success "Reproducible build env loaded (RUSTFLAGS path remapping, SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-unset})"
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
build_for_target "$target"
|
||||
done
|
||||
|
||||
verify_jni_symbols
|
||||
verify_ndk_stamp
|
||||
|
||||
print_header "Build complete"
|
||||
echo ""
|
||||
echo "Libraries written to: $OUTPUT_DIR"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Verify 16KB page alignment: readelf -l <lib> | grep LOAD"
|
||||
echo " 2. Build the app: make build"
|
||||
echo " 3. Test on device"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
# Deterministic build environment for libarti_android.so.
|
||||
#
|
||||
# Sourced by build-arti.sh (Android targets) and build-arti-host.sh (host target)
|
||||
# so the two paths stay in lockstep. Makes the Rust build byte-for-byte
|
||||
# reproducible, which is what lets F-Droid / Zapstore / any third party rebuild
|
||||
# the shipped .so from this tag and confirm it matches.
|
||||
#
|
||||
# The caller must already have set:
|
||||
# SCRIPT_DIR — tools/arti-build
|
||||
# ARTI_SOURCE_DIR — the Arti clone (.arti-source)
|
||||
#
|
||||
# The three things that otherwise make a Rust cdylib non-reproducible, and the
|
||||
# fix for each:
|
||||
# 1. Compiler version -> pinned by rust-toolchain.toml (rustup auto-installs).
|
||||
# 2. Dependency versions -> pinned by the committed Cargo.lock + `cargo --locked`.
|
||||
# 3. Absolute build paths embedded in panic locations / strings
|
||||
# -> --remap-path-prefix rewrites them to stable virtual
|
||||
# paths so two machines with different $HOME / checkout
|
||||
# dirs produce identical bytes.
|
||||
|
||||
# Disable incremental compilation — its on-disk cache can perturb codegen order.
|
||||
export CARGO_INCREMENTAL=0
|
||||
|
||||
# Where Cargo caches the crates.io registry + git deps (absolute, host-specific).
|
||||
export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}"
|
||||
|
||||
# Rewrite every host-specific absolute prefix that rustc would otherwise bake
|
||||
# into the binary. (Rust's own std is already remapped to /rustc/<hash> by the
|
||||
# distributed toolchain, so pinning the version in rust-toolchain.toml covers it.)
|
||||
REPRO_RUSTFLAGS="--remap-path-prefix=${CARGO_HOME}=/cargo"
|
||||
REPRO_RUSTFLAGS="${REPRO_RUSTFLAGS} --remap-path-prefix=${ARTI_SOURCE_DIR}=/arti"
|
||||
REPRO_RUSTFLAGS="${REPRO_RUSTFLAGS} --remap-path-prefix=${SCRIPT_DIR}=/arti-build"
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} ${REPRO_RUSTFLAGS}"
|
||||
|
||||
# Pin SOURCE_DATE_EPOCH to the commit the Arti tag points at — deterministic for
|
||||
# a given ARTI_VERSION, and independent of when the build actually runs.
|
||||
if [ -d "${ARTI_SOURCE_DIR}/.git" ]; then
|
||||
_epoch="$(git -C "${ARTI_SOURCE_DIR}" log -1 --format=%ct 2>/dev/null || true)"
|
||||
[ -n "${_epoch}" ] && export SOURCE_DATE_EPOCH="${_epoch}"
|
||||
unset _epoch
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
# Pin the exact Rust toolchain used to build libarti_android.so.
|
||||
#
|
||||
# Reproducibility: rustc output is only stable for a fixed compiler version, so
|
||||
# everyone who rebuilds the shipped .so (us, F-Droid, an independent verifier)
|
||||
# must use this exact toolchain. rustup reads this file automatically and
|
||||
# installs the pinned version + the Android targets on first invocation of the
|
||||
# build scripts. Bump this in lockstep with ARTI_VERSION / Cargo.lock and
|
||||
# re-verify (see README.md → "Reproducible builds").
|
||||
[toolchain]
|
||||
channel = "1.98.1"
|
||||
profile = "minimal"
|
||||
components = ["rustc", "cargo", "rust-std"]
|
||||
# The only ABI in build.gradle.kts -> ndk.abiFilters.
|
||||
targets = [
|
||||
"aarch64-linux-android",
|
||||
]
|
||||
@@ -0,0 +1,680 @@
|
||||
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};
|
||||
use arti_client::config::TorClientConfigBuilder;
|
||||
// `kind()` is a trait method (tor_error::HasKind), not inherent, so the trait has to be in
|
||||
// scope wherever we classify a connect failure. Both are re-exported by arti-client.
|
||||
use arti_client::HasKind;
|
||||
use tor_rtcompat::PreferredRuntime;
|
||||
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
|
||||
// ============================================================================
|
||||
// Global State
|
||||
// ============================================================================
|
||||
|
||||
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<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
|
||||
// released, and the next initialize() fails.
|
||||
static BOOTSTRAP_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
|
||||
// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers
|
||||
// — otherwise their Arc<TorClient> clones keep the client alive and the
|
||||
// state file lock would not be released for the next initialize().
|
||||
static HANDLER_TASKS: Mutex<Vec<tokio::task::JoinHandle<()>>> = Mutex::new(Vec::new());
|
||||
static INIT_ONCE: Once = Once::new();
|
||||
|
||||
// ============================================================================
|
||||
// Logging
|
||||
// ============================================================================
|
||||
|
||||
fn send_log_to_java(message: String) {
|
||||
let vm_opt = JAVA_VM.lock().unwrap();
|
||||
let callback_opt = LOG_CALLBACK.lock().unwrap();
|
||||
|
||||
if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) {
|
||||
// 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,
|
||||
jni_str!("onLogLine"),
|
||||
jni_sig!("(Ljava/lang/String;)V"),
|
||||
&[(&jmessage).into()],
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! log_info {
|
||||
($($arg:tt)*) => {{
|
||||
let msg = format!($($arg)*);
|
||||
send_log_to_java(msg);
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! log_error {
|
||||
($($arg:tt)*) => {{
|
||||
let msg = format!("ERROR: {}", format!($($arg)*));
|
||||
send_log_to_java(msg);
|
||||
}};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JNI Functions — package: dev.zapstore.app.transport
|
||||
// ============================================================================
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_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"));
|
||||
env.new_string(version)
|
||||
})
|
||||
.resolve::<ThrowRuntimeExAndDefault>()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_setLogCallback<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass<'caller>,
|
||||
callback: JObject<'caller>,
|
||||
) {
|
||||
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");
|
||||
}
|
||||
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_dev_zapstore_app_transport_ArtiNative_initialize<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass<'caller>,
|
||||
data_dir: JString<'caller>,
|
||||
) -> jint {
|
||||
// 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() {
|
||||
log_info!("Arti already initialized, reusing existing client");
|
||||
return 0;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
INIT_ONCE.call_once(|| {
|
||||
// arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
|
||||
// implicitly — without this, TorClient::create_bootstrapped panics on
|
||||
// first TLS handshake. install_default() returns Err if a provider is
|
||||
// already installed, which is fine; we just want at-least-one.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => {
|
||||
log_info!("Tokio runtime created successfully");
|
||||
*TOKIO_RUNTIME.lock().unwrap() = Some(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Failed to create Tokio runtime: {:?}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
|
||||
let runtime = match runtime_guard.as_ref() {
|
||||
Some(rt) => rt,
|
||||
None => {
|
||||
log_error!("Tokio runtime not initialized");
|
||||
return -2;
|
||||
}
|
||||
};
|
||||
|
||||
let data_path = PathBuf::from(data_dir_str);
|
||||
let cache_dir = data_path.join("cache");
|
||||
let state_dir = data_path.join("state");
|
||||
|
||||
std::fs::create_dir_all(&cache_dir).ok();
|
||||
std::fs::create_dir_all(&state_dir).ok();
|
||||
|
||||
let outcome: jint = runtime.block_on(async {
|
||||
log_info!("Creating Arti client...");
|
||||
|
||||
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 = match builder.build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log_error!("Failed to build Tor config: {:?}", e);
|
||||
return -3;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the client WITHOUT waiting for the directory.
|
||||
//
|
||||
// `create_bootstrapped` used to block this JNI call — and the Kotlin lifecycle lock it
|
||||
// holds — for the entire directory download: 12.6-33.8s measured on a cold install. The
|
||||
// app treated Tor as absent for all of it, because `activePortOrNull` stays null until
|
||||
// status flips to Active, so every relay dial fell back to 127.0.0.1:9050 (the Orbot
|
||||
// default) where nothing listens, and failed instantly into backoff.
|
||||
//
|
||||
// With `BootstrapBehavior::OnDemand` the client is usable the moment it exists and each
|
||||
// stream waits for the directory itself. The SOCKS proxy binds right away, so a dial
|
||||
// issued mid-download queues on its own circuit instead of failing. Readiness stops being
|
||||
// a global gate the app has to poll and becomes a property of individual connections.
|
||||
//
|
||||
// The `_async` variant is deliberate: it allows a short grace period for the state file
|
||||
// lock, which a destroy()/initialize() cycle needs, where the sync one waits not at all.
|
||||
let client = match TorClient::builder()
|
||||
.config(config)
|
||||
.bootstrap_behavior(BootstrapBehavior::OnDemand)
|
||||
.create_unbootstrapped_async()
|
||||
.await
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
*ARTI_CLIENT.lock().unwrap() = Some(Arc::clone(&client));
|
||||
|
||||
// Start the download now rather than leaving it for the first stream to trigger, so it
|
||||
// overlaps the login screen exactly as it used to, and publish the outcome so Kotlin can
|
||||
// move the status from Bootstrapping to Active at the right moment.
|
||||
let handle = tokio::spawn(async move {
|
||||
let started = std::time::Instant::now();
|
||||
match client.bootstrap().await {
|
||||
Ok(()) => log_info!(
|
||||
"Arti directory bootstrap complete after {}ms",
|
||||
started.elapsed().as_millis()
|
||||
),
|
||||
// Not fatal and deliberately not latched anywhere: with OnDemand the next stream
|
||||
// retries the bootstrap on its own, and isBootstrapped() reports live readiness, so
|
||||
// a recovery after this point is picked up without us having to model it.
|
||||
Err(e) => log_error!(
|
||||
"Arti directory bootstrap failed after {}ms (streams will retry): {:?}",
|
||||
started.elapsed().as_millis(),
|
||||
e
|
||||
),
|
||||
}
|
||||
});
|
||||
if let Some(previous) = BOOTSTRAP_TASK.lock().unwrap().replace(handle) {
|
||||
previous.abort();
|
||||
}
|
||||
|
||||
log_info!("Arti client created (unbootstrapped; directory downloading in background)");
|
||||
0
|
||||
});
|
||||
|
||||
if outcome == 0 {
|
||||
log_info!("Arti initialized successfully");
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Start the SOCKS5 proxy on the specified port.
|
||||
/// Can be called multiple times — stops any existing listener first.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_startSocksProxy(
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
port: jint,
|
||||
) -> jint {
|
||||
log_info!("Starting SOCKS proxy on port {}", port);
|
||||
|
||||
// Stop any existing SOCKS server first
|
||||
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
|
||||
log_info!("Aborting previous SOCKS server task");
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let client_guard = ARTI_CLIENT.lock().unwrap();
|
||||
let client = match client_guard.as_ref() {
|
||||
Some(c) => Arc::clone(c),
|
||||
None => {
|
||||
log_error!("Arti client not initialized — call initialize() first");
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
drop(client_guard);
|
||||
|
||||
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
|
||||
let runtime = match runtime_guard.as_ref() {
|
||||
Some(rt) => rt,
|
||||
None => {
|
||||
log_error!("Tokio runtime not initialized");
|
||||
return -2;
|
||||
}
|
||||
};
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
let bind_result = runtime.block_on(async {
|
||||
tokio::net::TcpListener::bind(&addr).await
|
||||
});
|
||||
|
||||
let listener = match bind_result {
|
||||
Ok(l) => {
|
||||
log_info!("SOCKS proxy bound to {}", addr);
|
||||
l
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e);
|
||||
return -3;
|
||||
}
|
||||
};
|
||||
|
||||
let handle = runtime.spawn(async move {
|
||||
log_info!("Sufficiently bootstrapped; system SOCKS now functional");
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _peer_addr)) => {
|
||||
let client_clone = Arc::clone(&client);
|
||||
let h = tokio::spawn(async move {
|
||||
if let Err(e) = handle_socks_connection(stream, client_clone).await {
|
||||
log_error!("SOCKS connection error: {:?}", e);
|
||||
}
|
||||
});
|
||||
let mut handlers = HANDLER_TASKS.lock().unwrap();
|
||||
handlers.retain(|h| !h.is_finished());
|
||||
handlers.push(h);
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Failed to accept SOCKS connection: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*SOCKS_TASK.lock().unwrap() = Some(handle);
|
||||
log_info!("SOCKS proxy started on port {}", port);
|
||||
0
|
||||
}
|
||||
|
||||
/// Handle a single SOCKS5 connection through Tor.
|
||||
/// Maps an Arti connect failure onto the SOCKS5 reply code that means the same thing.
|
||||
///
|
||||
/// Why this matters: every failure used to be reported as 0x05 (connection refused), so a
|
||||
/// domain that no longer exists, an exit that timed out, and a genuinely refused port were
|
||||
/// indistinguishable to the client. Java's SOCKS client renders 0x05 as
|
||||
/// `SocketException("SOCKS: Connection refused")`, so the whole failure taxonomy collapsed
|
||||
/// into one opaque string and the caller could only apply its most generic retry policy.
|
||||
/// Measured on a cold start: 639 of ~768 relay failures arrived this way.
|
||||
///
|
||||
/// The codes below are the ones Java surfaces with distinct messages, which is what lets the
|
||||
/// caller tell "this relay is gone" from "this circuit had a bad minute":
|
||||
/// 0x01 general failure -> "SOCKS server general failure"
|
||||
/// 0x02 not allowed -> "SOCKS: Connection not allowed by ruleset"
|
||||
/// 0x03 network unreachable -> "SOCKS: Network unreachable"
|
||||
/// 0x04 host unreachable -> "SOCKS: Host unreachable"
|
||||
/// 0x05 connection refused -> "SOCKS: Connection refused"
|
||||
/// 0x06 TTL expired -> "SOCKS: TTL expired"
|
||||
///
|
||||
/// Note on name-resolution failures: Arti documents `RemoteHostResolutionFailed` as
|
||||
/// retryable, because an exit's resolver failing is not proof the name is dead. We still map
|
||||
/// it to 0x04 rather than 0x01, because the caller's response to 0x04 is a bounded backoff,
|
||||
/// not permanent condemnation — which *is* a retry, just a slower one. Probing the relays
|
||||
/// that produced this error found 17 of 21 to be NXDOMAIN from a normal resolver, so the
|
||||
/// conservative reading costs far more than it saves. `RemoteHostNotFound` (the unambiguous
|
||||
/// case) maps to 0x04 too.
|
||||
fn socks_reply_for(e: &arti_client::Error) -> u8 {
|
||||
use arti_client::ErrorKind::*;
|
||||
|
||||
match e.kind() {
|
||||
// The name does not resolve, or the exit could not resolve it.
|
||||
RemoteHostNotFound | RemoteHostResolutionFailed => 0x04,
|
||||
// The host answered and said no. A real, specific refusal.
|
||||
RemoteConnectionRefused => 0x05,
|
||||
// The exit refuses this destination by policy; another exit may allow it.
|
||||
ExitPolicyRejected => 0x02,
|
||||
// No route from the exit.
|
||||
RemoteNetworkFailed => 0x03,
|
||||
// Timed out — transient by nature, at the exit or against our own threshold.
|
||||
ExitTimeout | RemoteNetworkTimeout => 0x06,
|
||||
// Anything else stays deliberately vague rather than being mislabelled.
|
||||
_ => 0x01,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socks_connection(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
client: Arc<TorClient<PreferredRuntime>>,
|
||||
) -> Result<()> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
|
||||
// SOCKS5 handshake: read version + methods
|
||||
let n = stream.read(&mut buf).await?;
|
||||
if n < 2 {
|
||||
return Err(anyhow::anyhow!("Invalid SOCKS handshake"));
|
||||
}
|
||||
|
||||
// No auth required
|
||||
stream.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
// Read request
|
||||
let n = stream.read(&mut buf).await?;
|
||||
if n < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid SOCKS request"));
|
||||
}
|
||||
|
||||
let version = buf[0];
|
||||
let cmd = buf[1];
|
||||
let atyp = buf[3];
|
||||
|
||||
if version != 0x05 {
|
||||
return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version));
|
||||
}
|
||||
|
||||
if cmd != 0x01 {
|
||||
stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd));
|
||||
}
|
||||
|
||||
let (target_host, target_port) = match atyp {
|
||||
0x01 => {
|
||||
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
(ip, port)
|
||||
}
|
||||
0x03 => {
|
||||
let len = buf[4] as usize;
|
||||
if n < 5 + len + 2 {
|
||||
return Err(anyhow::anyhow!("Invalid domain name length"));
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]);
|
||||
(domain, port)
|
||||
}
|
||||
0x04 => {
|
||||
if n < 22 {
|
||||
stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
return Err(anyhow::anyhow!("Truncated IPv6 request"));
|
||||
}
|
||||
let ip = format!(
|
||||
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}",
|
||||
buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11],
|
||||
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]
|
||||
);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
(ip, port)
|
||||
}
|
||||
_ => {
|
||||
stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
return Err(anyhow::anyhow!("Unsupported address type: {}", atyp));
|
||||
}
|
||||
};
|
||||
|
||||
let tor_stream = match client.connect((target_host.as_str(), target_port)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let reply = socks_reply_for(&e);
|
||||
log_error!(
|
||||
"Failed to connect through Tor to {}:{}: kind={:?} socks_reply=0x{:02x} {:?}",
|
||||
target_host, target_port, e.kind(), reply, e
|
||||
);
|
||||
stream.write_all(&[0x05, reply, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// SOCKS5 success
|
||||
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
|
||||
// Bidirectional forwarding
|
||||
let (mut client_read, mut client_write) = stream.split();
|
||||
let (mut tor_read, mut tor_write) = tor_stream.split();
|
||||
|
||||
tokio::select! {
|
||||
r = tokio::io::copy(&mut client_read, &mut tor_write) => {
|
||||
if let Err(ref e) = r { log_error!("Client->Tor error: {:?}", e); }
|
||||
}
|
||||
r = tokio::io::copy(&mut tor_read, &mut client_write) => {
|
||||
if let Err(ref e) = r { log_error!("Tor->Client error: {:?}", e); }
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the SOCKS proxy listener. The TorClient stays alive.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_stopSocksProxy(
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
) -> jint {
|
||||
log_info!("Stopping SOCKS proxy...");
|
||||
|
||||
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let rt_handle = TOKIO_RUNTIME
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|rt| rt.handle().clone());
|
||||
if let Some(rh) = rt_handle {
|
||||
rh.block_on(async {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: TorClient is NOT destroyed — it persists for reuse.
|
||||
|
||||
log_info!("SOCKS proxy stopped");
|
||||
0
|
||||
}
|
||||
|
||||
/// Directory-download progress in permille (0..1000), or -1 when there is no client.
|
||||
///
|
||||
/// Lets Kotlin tell a slow download from a stalled one. A timeout cannot: measured cold downloads
|
||||
/// ran 12.6-34.4s on the same hardware and network, so any fixed patience is either short enough to
|
||||
/// kill healthy ones or long enough to sit on a dead one. Forward progress separates them exactly.
|
||||
///
|
||||
/// Deliberately does NOT surface `BootstrapStatus::blocked()`. Arti documents it as best-effort and
|
||||
/// warns it "may declare that Arti is stuck for reasons that are incorrect; or it may declare that
|
||||
/// the client is not stuck when in fact no progress is being made" — acting on that would trade a
|
||||
/// measurable signal for a guess.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_bootstrapProgressPermille(
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
) -> jint {
|
||||
match ARTI_CLIENT.lock().unwrap().as_ref() {
|
||||
Some(client) => (client.bootstrap_status().as_frac() * 1000.0).clamp(0.0, 1000.0) as jint,
|
||||
None => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Live readiness: 1 = ready for traffic, 0 = not yet, -1 = no client at all.
|
||||
///
|
||||
/// Asks Arti itself (`bootstrap_status().ready_for_traffic()`, a cheap borrow-and-clone of a small
|
||||
/// struct) rather than latching the outcome of the one background `bootstrap()` call. That call can
|
||||
/// fail while the client stays perfectly usable — with OnDemand the next stream just retries — so a
|
||||
/// latched failure would report "not bootstrapped" forever against a Tor that actually works,
|
||||
/// leaving the UI wrong and the exit-rotation self-heal disabled.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_isBootstrapped(
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
) -> jint {
|
||||
match ARTI_CLIENT.lock().unwrap().as_ref() {
|
||||
Some(client) => {
|
||||
if client.bootstrap_status().ready_for_traffic() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
None => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy the TorClient — used by self-heal paths in Kotlin when Tor is
|
||||
/// stuck and the in-memory state (guards, circuits) needs to be rebuilt
|
||||
/// from scratch. Aborts the SOCKS listener and all in-flight per-connection
|
||||
/// handlers, then drops the static Arc so Arti's state file lock can be
|
||||
/// released. The next call to [initialize] will create a fresh TorClient
|
||||
/// (and re-bootstrap).
|
||||
#[no_mangle]
|
||||
pub extern "C" fn Java_dev_zapstore_app_transport_ArtiNative_destroy(
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
) -> jint {
|
||||
log_info!("Destroying Arti client");
|
||||
|
||||
// Clone the runtime handle and release the TOKIO_RUNTIME mutex immediately —
|
||||
// we will hold it for ~500ms below, and other JNI calls that need the runtime
|
||||
// (e.g. a Kotlin start() racing with us) would otherwise block on this mutex.
|
||||
let rt_handle = TOKIO_RUNTIME
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|rt| rt.handle().clone());
|
||||
|
||||
// Abort the listener and wait for it to actually terminate before draining
|
||||
// HANDLER_TASKS. The accept loop has no .await between `accept` and
|
||||
// `HANDLER_TASKS.push(h)`, so abort() alone is racy: a handler can be spawned
|
||||
// and pushed AFTER our drain. Awaiting the JoinHandle (with timeout) closes
|
||||
// that window — no new handlers can be pushed once the listener task is gone.
|
||||
let socks_handle = SOCKS_TASK.lock().unwrap().take();
|
||||
if let (Some(h), Some(rh)) = (socks_handle, rt_handle.as_ref()) {
|
||||
h.abort();
|
||||
rh.block_on(async {
|
||||
let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), h).await;
|
||||
});
|
||||
}
|
||||
|
||||
// The background directory download holds an Arc<TorClient> too, for as long as it runs —
|
||||
// which on a dead network is indefinitely. Abort it with the handlers or the state file lock
|
||||
// outlives this destroy() and the next initialize() cannot take it.
|
||||
if let Some(h) = BOOTSTRAP_TASK.lock().unwrap().take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
// Abort all in-flight handlers — each holds an Arc<TorClient> clone, and
|
||||
// the client cannot drop (state file lock cannot release) while any clone
|
||||
// is alive.
|
||||
let handlers = std::mem::take(&mut *HANDLER_TASKS.lock().unwrap());
|
||||
for h in &handlers {
|
||||
h.abort();
|
||||
}
|
||||
drop(handlers);
|
||||
|
||||
// Give tokio a moment to actually cancel and drop the task frames so the
|
||||
// handler Arcs are released before we drop our static one.
|
||||
if let Some(rh) = rt_handle {
|
||||
rh.block_on(async {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Drop the static Arc. If any handler is still holding a clone, the TorClient stays alive
|
||||
// until that handler finishes — in which case the next initialize() fails and Kotlin leaves
|
||||
// status Connecting for TorManager's self-heal watchdog to retry. (It no longer wipes all Arti
|
||||
// data inline: that turned a transient failure into a lost guard sample and a lost consensus.)
|
||||
let _ = ARTI_CLIENT.lock().unwrap().take();
|
||||
|
||||
log_info!("Arti client destroyed");
|
||||
0
|
||||
}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify that libarti_android.so builds reproducibly.
|
||||
#
|
||||
# Builds the Arti native library twice from a clean state and confirms the two
|
||||
# outputs are byte-for-byte identical. Both builds compile in the canonical path
|
||||
# (/tmp/zapstore-arti-build), so a match here means any checkout — ours,
|
||||
# F-Droid's, an auditor's — produces the same bytes. See README.md →
|
||||
# "Reproducible builds".
|
||||
#
|
||||
# Usage:
|
||||
# ./verify-reproducible.sh # arm64-v8a (the only shipped ABI)
|
||||
#
|
||||
# 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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
JNILIBS="$PROJECT_ROOT/src/main/jniLibs"
|
||||
PASSTHRU=("$@")
|
||||
|
||||
# Portable sha256 (coreutils sha256sum on Linux, shasum on macOS).
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi
|
||||
}
|
||||
|
||||
# ABI list comes from build-arti.sh --print-abis so this script cannot hash a
|
||||
# different set than the one it just rebuilt. Under `set -e` a failing
|
||||
# --print-abis aborts here.
|
||||
ABIS="$("$SCRIPT_DIR/build-arti.sh" --print-abis ${PASSTHRU[@]+"${PASSTHRU[@]}"} | tr '\n' ' ')"
|
||||
ABIS="${ABIS% }"
|
||||
|
||||
# sha256 of each built .so, keyed by ABI dir (relative paths → stable keys).
|
||||
hashes() {
|
||||
( 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/zapstore-arti-build}"
|
||||
echo
|
||||
|
||||
echo "### Build 1 of 2 (clean)…"
|
||||
"$SCRIPT_DIR/build-arti.sh" --clean ${PASSTHRU[@]+"${PASSTHRU[@]}"}
|
||||
H1="$(hashes)"
|
||||
echo "--- build 1 hashes ---"; echo "$H1"; echo
|
||||
|
||||
echo "### Build 2 of 2 (clean)…"
|
||||
"$SCRIPT_DIR/build-arti.sh" --clean ${PASSTHRU[@]+"${PASSTHRU[@]}"}
|
||||
H2="$(hashes)"
|
||||
echo "--- build 2 hashes ---"; echo "$H2"; echo
|
||||
|
||||
if [ "$H1" = "$H2" ]; then
|
||||
echo "✅ REPRODUCIBLE — both clean builds produced identical .so bytes."
|
||||
else
|
||||
echo "❌ NOT REPRODUCIBLE — the two builds differ:"
|
||||
diff <(echo "$H1") <(echo "$H2") || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Informational: is the binary committed in git already the reproducible one?
|
||||
echo
|
||||
echo "### vs. the committed binaries:"
|
||||
BUILT_PATHS=""
|
||||
for abi in $ABIS; do
|
||||
BUILT_PATHS="$BUILT_PATHS 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$BUILT_PATHS && git commit"
|
||||
fi
|
||||
Reference in New Issue
Block a user