diff --git a/BUILDING.md b/BUILDING.md index 0a121497a0..0c5be556fe 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -121,6 +121,12 @@ reproducible one. The Rust toolchain stays irrelevant either way; Studio/AGP fetches the pinned NDK on demand, or pre-install it with `sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`. +> **One Arti library per ABI split.** The APK is split four ways (`arm64-v8a`, +> `x86_64`, `armeabi-v7a`, `x86`) and every split needs its own +> `libarti_android.so`; a split without one installs and runs with Tor silently +> unavailable. The `verifyArtiAbis` Gradle task fails the build if the two lists +> drift, and names the `build-arti.sh --target=…` to run. + --- ## Per-format build commands diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 4910dd397c..f33448ec6c 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -67,6 +67,14 @@ afterEvaluate { } } +// Every ABI we split the APK for, and therefore every ABI that needs its own +// libarti_android.so under src/main/jniLibs/ (see tools/arti-build/). The two +// lists drifted once: the splits shipped four ABIs while Arti was built for two, +// so the armeabi-v7a and x86 APKs installed and ran with the dependencies' native +// libraries all present (secp256k1's JNI ships every ABI) and Tor alone dead for +// the life of the install. `verifyArtiAbis` below keeps them in step. +val shippedAbis = listOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + android { namespace = "com.vitorpamplona.amethyst" compileSdk = @@ -281,7 +289,7 @@ android { abi { isEnable = !disableAbiSplits reset() - include("x86", "x86_64", "arm64-v8a", "armeabi-v7a") + include(*shippedAbis.toTypedArray()) isUniversalApk = !disableUniversalApk } } @@ -344,8 +352,9 @@ android { unitTests.isReturnDefaultValues = true // Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android") // find the desktop-host build of our Arti JNI shim. The Android .so - // variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded - // on-device — this Linux x86_64 .so is just for JVM unit-test runs. + // variants live in src/main/jniLibs// — one per ABI in [shippedAbis] + // — and are loaded on-device; this Linux x86_64 .so is just for JVM + // unit-test runs. // -Pamethyst.arti.integration=true opts the (slow, network-dependent) // tests in; see TorArtiNativeIntegrationTest.kdoc. unitTests.all { test -> @@ -360,6 +369,88 @@ android { } } +// Every ABI split must carry Arti, or it ships an APK that is whole except for +// Tor. Nothing else catches that: AGP happily assembles a split out of whatever +// .so files the dependencies provide, the APK installs and runs, and the gap +// only surfaces at System.loadLibrary time on a user's device — where +// TorManager's flow swallows the UnsatisfiedLinkError and leaves the status Off +// forever. Checked at build time instead, against the same list the splits use. +val verifyArtiAbis = + tasks.register("verifyArtiAbis") { + group = "verification" + description = "Checks that every ABI in the APK splits has a libarti_android.so for that architecture." + + val jniLibs = file("src/main/jniLibs") + val abis = shippedAbis + // Per ABI: the Rust target triple (so a failure names the exact build + // command) and the ELF identity the library must have — 32/64-bit class + // (header byte 4) and e_machine (bytes 18-19, little-endian on every + // Android ABI we ship). Existence alone is not enough: a truncated file, + // an empty placeholder, or arm64's .so copied into x86/ all load as + // nothing on device, which is the same silent dead Tor this task exists + // to prevent — and unlike a missing file, those look fine in git. + val expected = + mapOf( + "arm64-v8a" to Triple("aarch64-linux-android", 2, 0xB7), + "x86_64" to Triple("x86_64-linux-android", 2, 0x3E), + "armeabi-v7a" to Triple("armv7-linux-androideabi", 1, 0x28), + "x86" to Triple("i686-linux-android", 1, 0x03), + ) + + doLast { + val bitness = mapOf(1 to "32-bit", 2 to "64-bit") + val problems = mutableListOf>() + + abis.forEach { abi -> + val lib = File(jniLibs, "$abi/libarti_android.so") + val want = expected[abi] + val header = ByteArray(20) + val read = if (lib.isFile) lib.inputStream().use { it.read(header) } else -1 + + val problem = + when { + !lib.isFile -> "no libarti_android.so" + want == null -> "no expected ELF identity recorded for this ABI" + read < header.size || + header[0] != 0x7F.toByte() || + header[1] != 'E'.code.toByte() || + header[2] != 'L'.code.toByte() || + header[3] != 'F'.code.toByte() -> "not an ELF file (truncated or corrupt)" + header[4].toInt() != want.second -> + "${bitness[header[4].toInt()] ?: "unknown-class"} ELF, expected ${bitness[want.second]}" + else -> { + val machine = (header[18].toInt() and 0xFF) or ((header[19].toInt() and 0xFF) shl 8) + if (machine != want.third) { + "built for ELF machine 0x%02x, expected 0x%02x".format(machine, want.third) + } else { + null + } + } + } + + if (problem != null) problems += abi to problem + } + + if (problems.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("libarti_android.so is missing or wrong for ${problems.size} ABI split(s):") + problems.forEach { (abi, problem) -> appendLine(" $abi: $problem") } + appendLine("Those APK splits would install with Tor permanently unavailable.") + appendLine("Rebuild them (tools/arti-build/README.md):") + problems.forEach { (abi, _) -> + val triple = expected[abi]?.first ?: "" + appendLine(" ./tools/arti-build/build-arti.sh --target=$triple") + } + append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.") + }, + ) + } + } + } + +tasks.named("preBuild") { dependsOn(verifyArtiAbis) } + // androidx.appfunctions-compiler runs in a per-module mode by default, // emitting only the dispatcher Kotlin code. The aggregator that builds // the `app_functions.xml` asset (which the system reads to discover our diff --git a/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so b/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so new file mode 100755 index 0000000000..805b0cc712 Binary files /dev/null and b/amethyst/src/main/jniLibs/armeabi-v7a/libarti_android.so differ diff --git a/amethyst/src/main/jniLibs/x86/libarti_android.so b/amethyst/src/main/jniLibs/x86/libarti_android.so new file mode 100755 index 0000000000..8ee47b981b Binary files /dev/null and b/amethyst/src/main/jniLibs/x86/libarti_android.so differ diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 8305992d0d..9d9987a064 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -8,8 +8,8 @@ JNI wrapper built directly from Arti source. | | Guardian Project AAR | Custom build | |---|---|---| -| **Size** | ~140MB | ~11MB | -| **16KB pages** | No | Yes (rustc aligns Android targets to 16 KiB) | +| **Size** | ~140MB | ~22MB for all four ABIs — 4-6MB in the APK a device installs | +| **16KB pages** | No | Yes on the 64-bit ABIs (rustc aligns them to 16 KiB) | | **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) | | **Version** | Behind | Pinned to latest (see [`ARTI_VERSION`](ARTI_VERSION)) | @@ -89,8 +89,9 @@ release profile in `Cargo.toml` (`lto`, `codegen-units = 1`, `strip`, From `tools/arti-build/`, the helper builds twice from clean and diffs the output: ```bash -./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +./verify-reproducible.sh # all four shipped ABIs ./verify-reproducible.sh --release # arm64-v8a only (faster) +./verify-reproducible.sh --target=armv7-linux-androideabi # one ABI ``` It prints `✅ REPRODUCIBLE` when two clean builds produce identical bytes, then @@ -108,8 +109,12 @@ repo is checked out. 2. **Android targets** ```bash - rustup target add aarch64-linux-android x86_64-linux-android + rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` + One per ABI the APK is split for. They are also listed in + `rust-toolchain.toml`, so a first invocation of the build scripts installs + whatever is missing. 3. **cargo-ndk** — the release the pinned output was verified with: ```bash @@ -138,12 +143,19 @@ repo is checked out. ```bash cd tools/arti-build -# Build for all targets (arm64 + x86_64) +# Build every shipped ABI (arm64-v8a, x86_64, armeabi-v7a, x86) ./build-arti.sh -# Build arm64 only (for release APKs) +# Build arm64-v8a only — a fast local loop, NOT enough to cut a release: +# the APK splits ship four ABIs and each one needs its own libarti_android.so ./build-arti.sh --release +# Build a single ABI without touching the other committed .so files +./build-arti.sh --target=armv7-linux-androideabi + +# Print the jniLibs ABI dirs a given invocation would write, then exit +./build-arti.sh --print-abis --release # -> arm64-v8a + # Clean rebuild from scratch ./build-arti.sh --clean ``` @@ -153,7 +165,7 @@ The script will: 2. Check out the version pinned in `ARTI_VERSION` 3. Copy the JNI wrapper into the source tree 4. Compile with `cargo-ndk` for each target architecture -5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64}/` +5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64,armeabi-v7a,x86}/` 6. Verify JNI symbols are exported correctly ## Output @@ -162,10 +174,34 @@ The script will: amethyst/src/main/jniLibs/ ├── arm64-v8a/ │ └── libarti_android.so (~5-6 MB) -└── x86_64/ - └── libarti_android.so (~6-7 MB, emulator support) +├── x86_64/ +│ └── libarti_android.so (~6-7 MB, emulator support) +├── armeabi-v7a/ +│ └── libarti_android.so (~3-4 MB, 32-bit ARM devices) +└── x86/ + └── libarti_android.so (~6 MB, 32-bit x86 images) ``` +**One per ABI split, always.** `amethyst/build.gradle.kts` splits the APK four +ways and `create-release.yml` publishes all four, so an ABI missing from this +tree ships an APK that is complete except for Arti: the dependencies' native +libraries are all there (secp256k1's JNI, for one, ships every ABI), the app +installs and runs, and Tor alone is dead for that install. `System.loadLibrary` +throws, `TorManager`'s status flow swallows the error, and Tor reports Off +forever. With the defaults (`TorType.INTERNAL`, DM relays and unknown relays +routed over Tor) those relays then dial a SOCKS port nothing listens on and +never connect — so the ABI loses its DMs too, not just Tor. + +The ABI list therefore lives in three places that must agree: `splits.abi` in +`amethyst/build.gradle.kts`, `targets` in `rust-toolchain.toml`, and `TARGETS` +in `build-arti.sh`. (`verify-reproducible.sh` has no copy of its own — it asks +`build-arti.sh --print-abis`, so it can never hash a different set than the one +it just rebuilt.) The `verifyArtiAbis` Gradle task, wired into `preBuild`, fails +the build when an ABI split has no `libarti_android.so` **or** has one that is +not an ELF of that architecture — a truncated file or arm64's library copied +into `x86/` loads as nothing on device, exactly like a missing one, and unlike a +missing one it looks fine in `git status`. + ## Verifying 16KB page alignment Google Play requires 16KB page-aligned native libraries. Verify with: @@ -178,6 +214,11 @@ 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. +The requirement is a 64-bit one — 16 KB pages exist only on 64-bit Android +devices — so it applies to `arm64-v8a` and `x86_64`. The 32-bit libraries +(`armeabi-v7a`, `x86`) link at the 4 KB alignment their targets specify +(`0x1000`), which is correct for them and not a regression to fix. + ## Checking which toolchain built a `.so` The shipped binaries say so themselves — useful when a rebuild does not match, or @@ -326,7 +367,8 @@ fatal. ### Rust targets not installed ```bash -rustup target add aarch64-linux-android x86_64-linux-android +rustup target add aarch64-linux-android x86_64-linux-android \ + armv7-linux-androideabi i686-linux-android ``` ### Build fails with dependency errors diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index babc4e48cc..438fac14ee 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -4,15 +4,26 @@ # # Prerequisites: # - Rust toolchain: rustup, cargo -# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android +# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android \ +# armv7-linux-androideabi i686-linux-android # - cargo-ndk: cargo install cargo-ndk # - Android NDK: the exact revision pinned in ANDROID_NDK_VERSION # (sdkmanager "ndk;") — see README.md -> "Reproducible builds" # # Usage: -# ./build-arti.sh # Build for all targets (arm64 + x86_64) -# ./build-arti.sh --release # Build arm64 only (for release) +# ./build-arti.sh # Build every shipped ABI (all four) +# ./build-arti.sh --release # arm64-v8a only (faster; NOT enough to cut a +# # release — the APK splits ship four ABIs) +# ./build-arti.sh --target= +# # build just this target, repeatable. Use it to +# # add one ABI without rewriting the other .so +# # files already committed under jniLibs/. # ./build-arti.sh --clean # Clean and rebuild +# ./build-arti.sh --print-abis # print the jniLibs ABI dirs this invocation +# # would write (honours --release/--target=), +# # then exit. This is how verify-reproducible.sh +# # learns the ABI list instead of keeping its +# # own copy of it. # set -euo pipefail @@ -56,26 +67,42 @@ OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs" LIB_NAME="libarti_android.so" MIN_SDK_VERSION=26 -# Default targets -TARGETS=("aarch64-linux-android" "x86_64-linux-android") -RELEASE_ONLY=false +# Default targets: one per ABI the APK is split for (amethyst/build.gradle.kts -> +# splits.abi). They must stay in sync — an ABI that gets an APK split but no +# libarti_android.so produces an install where every other native library is +# present, so the app looks healthy right up to the moment it tries to reach Tor, +# and then fails silently for the lifetime of that install. +TARGETS=("aarch64-linux-android" "x86_64-linux-android" "armv7-linux-androideabi" "i686-linux-android") CLEAN=false REGEN_LOCK=false +# Collects --target= selections; replaces TARGETS wholesale once any is given. +# A space-separated string, not an array: macOS still ships bash 3.2, where +# `${#empty_array[@]}` under `set -u` is an unbound-variable error. Target +# triples never contain spaces, so word splitting is exact here. +SELECTED_TARGETS="" +PRINT_ABIS=false # Parse arguments for arg in "$@"; do case $arg in - --release) RELEASE_ONLY=true; TARGETS=("aarch64-linux-android") ;; + --release) TARGETS=("aarch64-linux-android") ;; + --target=*) SELECTED_TARGETS="$SELECTED_TARGETS ${arg#--target=}" ;; + --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 [--release] [--clean] [--regen-lock] [--help]"; exit 0 ;; + --help) echo "Usage: $0 [--release] [--target=]... [--clean] [--regen-lock] [--print-abis] [--help]"; exit 0 ;; esac done +if [ -n "$SELECTED_TARGETS" ]; then + # shellcheck disable=SC2206 # deliberate word splitting; see SELECTED_TARGETS + TARGETS=($SELECTED_TARGETS) +fi + print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; } print_success() { echo -e "${GREEN}✓ $1${NC}"; } print_error() { echo -e "${RED}✗ $1${NC}"; } @@ -254,12 +281,16 @@ PATCH # ============================================================================ # 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" ;; x86_64-linux-android) echo "x86_64" ;; armv7-linux-androideabi) echo "armeabi-v7a" ;; i686-linux-android) echo "x86" ;; + *) print_error "Unknown Rust target '$1' — no jniLibs ABI maps to it" >&2; exit 1 ;; esac } @@ -395,6 +426,16 @@ verify_ndk_stamp() { # ============================================================================ 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. diff --git a/tools/arti-build/rust-toolchain.toml b/tools/arti-build/rust-toolchain.toml index cfb3a8367d..356afb295c 100644 --- a/tools/arti-build/rust-toolchain.toml +++ b/tools/arti-build/rust-toolchain.toml @@ -10,10 +10,14 @@ 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 -# build-arti.sh is extended to armv7/i686, add them here too — check_prerequisites -# also adds any missing target on the fly. +# One per ABI the APK is split for (see amethyst/build.gradle.kts -> splits.abi): +# every split that ships native code ships libarti_android.so too, or Tor is dead +# on that ABI for the life of the install. The `verifyArtiAbis` Gradle task holds +# the two lists together. check_prerequisites also adds any missing target on the +# fly. targets = [ "aarch64-linux-android", "x86_64-linux-android", + "armv7-linux-androideabi", + "i686-linux-android", ] diff --git a/tools/arti-build/verify-reproducible.sh b/tools/arti-build/verify-reproducible.sh index d9bc45d9e7..a1539dae67 100755 --- a/tools/arti-build/verify-reproducible.sh +++ b/tools/arti-build/verify-reproducible.sh @@ -9,8 +9,10 @@ # "Reproducible builds". # # Usage: -# ./verify-reproducible.sh # both ABIs (arm64-v8a + x86_64) +# ./verify-reproducible.sh # all four shipped ABIs # ./verify-reproducible.sh --release # arm64-v8a only (faster) +# ./verify-reproducible.sh --target=armv7-linux-androideabi +# # one ABI, by Rust target triple # # 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 @@ -33,10 +35,14 @@ sha256() { # 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 +# +# Asked of build-arti.sh with the very flags it is about to receive, rather than +# kept as a second copy of the ABI list here. A local copy would drift the moment +# an ABI is added: this script would rebuild it twice, hash neither, and still +# print ✅ REPRODUCIBLE — the same false pass the paragraph above describes. +# Under `set -e` a failing --print-abis (e.g. an unknown triple) 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() {