diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 1c1a37732f..4762a2e592 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -339,29 +339,68 @@ android { val verifyArtiAbis = tasks.register("verifyArtiAbis") { group = "verification" - description = "Checks that every ABI in the APK splits has a libarti_android.so." + 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 - // Rust target triple per ABI, so the failure says exactly what to build. - val triples = + // 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 "aarch64-linux-android", - "x86_64" to "x86_64-linux-android", - "armeabi-v7a" to "armv7-linux-androideabi", - "x86" to "i686-linux-android", + "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 missing = abis.filter { !File(jniLibs, "$it/libarti_android.so").exists() } - if (missing.isNotEmpty()) { + 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("No libarti_android.so for ABI(s): ${missing.joinToString()}.") + 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("Build them (tools/arti-build/README.md):") - missing.forEach { abi -> - val triple = triples[abi] ?: "" + 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.") diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md index 543bf630b5..26e42c84a5 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -135,6 +135,9 @@ cd tools/arti-build # 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 ``` @@ -173,9 +176,13 @@ 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`. The `verifyArtiAbis` Gradle task (wired into `preBuild`) -fails the build when an ABI split has no `libarti_android.so`, so the drift is -caught here rather than on a user's phone. +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 diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index 6c01937872..438fac14ee 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -19,6 +19,11 @@ # # 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 @@ -75,19 +80,21 @@ REGEN_LOCK=false # `${#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) 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] [--target=]... [--clean] [--regen-lock] [--help]"; exit 0 ;; + --help) echo "Usage: $0 [--release] [--target=]... [--clean] [--regen-lock] [--print-abis] [--help]"; exit 0 ;; esac done @@ -419,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/verify-reproducible.sh b/tools/arti-build/verify-reproducible.sh index 4160dc2c38..a1539dae67 100755 --- a/tools/arti-build/verify-reproducible.sh +++ b/tools/arti-build/verify-reproducible.sh @@ -30,33 +30,19 @@ sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi } -# Mirrors abi_dir_for() in build-arti.sh — kept in step with it, since a triple -# that maps to the wrong directory here would hash a library this run never -# rebuilt and call it reproducible. -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" ;; - *) echo "Unknown Rust target '$1'" >&2; exit 1 ;; - esac -} - # 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 armeabi-v7a x86" -SELECTED="" -for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do - case "$arg" in - --release) ABIS="arm64-v8a" ;; - --target=*) SELECTED="$SELECTED $(abi_dir_for "${arg#--target=}")" ;; - esac -done -[ -n "$SELECTED" ] && ABIS="${SELECTED# }" +# +# 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() {