fix(build): close two gaps in the Arti ABI guard

Audit of the previous commit turned up two ways the new checks could
still pass while the APK ships a dead Tor.

verify-reproducible.sh kept its own copy of the ABI list, a fourth one
next to splits.abi, rust-toolchain.toml and build-arti.sh's TARGETS. Add
an ABI that copy misses and the script rebuilds it twice, hashes neither,
and still prints REPRODUCIBLE — the same false pass its own comment says
was fixed for --release. It now asks `build-arti.sh --print-abis` with
the flags it is about to pass on, so the set it hashes is by construction
the set that was just built.

verifyArtiAbis only tested File.exists(). A zero-byte placeholder, a
truncated file, or arm64's library copied into x86/ all satisfied that
and all load as nothing on device — and unlike a missing file, they look
fine in git. It now reads the ELF header and checks the class and
e_machine for the ABI, which is what separates arm64's .so from armv7's
when both are the right size and both exist.

Verified: the guard still passes on the four committed libraries, and
rejects an empty file, a 64-bit .so in a 32-bit dir, and armv7's .so in
x86/ (same bitness, wrong machine) with the right rebuild command each
time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1p385SiQMd2Lhkbg2YKc
This commit is contained in:
Claude
2026-09-18 17:15:05 +00:00
parent 4406019059
commit ea8326f759
4 changed files with 88 additions and 39 deletions
+52 -13
View File
@@ -339,29 +339,68 @@ android {
val verifyArtiAbis = val verifyArtiAbis =
tasks.register("verifyArtiAbis") { tasks.register("verifyArtiAbis") {
group = "verification" 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 jniLibs = file("src/main/jniLibs")
val abis = shippedAbis val abis = shippedAbis
// Rust target triple per ABI, so the failure says exactly what to build. // Per ABI: the Rust target triple (so a failure names the exact build
val triples = // 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( mapOf(
"arm64-v8a" to "aarch64-linux-android", "arm64-v8a" to Triple("aarch64-linux-android", 2, 0xB7),
"x86_64" to "x86_64-linux-android", "x86_64" to Triple("x86_64-linux-android", 2, 0x3E),
"armeabi-v7a" to "armv7-linux-androideabi", "armeabi-v7a" to Triple("armv7-linux-androideabi", 1, 0x28),
"x86" to "i686-linux-android", "x86" to Triple("i686-linux-android", 1, 0x03),
) )
doLast { doLast {
val missing = abis.filter { !File(jniLibs, "$it/libarti_android.so").exists() } val bitness = mapOf(1 to "32-bit", 2 to "64-bit")
if (missing.isNotEmpty()) { val problems = mutableListOf<Pair<String, String>>()
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( throw GradleException(
buildString { 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("Those APK splits would install with Tor permanently unavailable.")
appendLine("Build them (tools/arti-build/README.md):") appendLine("Rebuild them (tools/arti-build/README.md):")
missing.forEach { abi -> problems.forEach { (abi, _) ->
val triple = triples[abi] ?: "<add the Rust target for $abi>" val triple = expected[abi]?.first ?: "<add the Rust target for $abi>"
appendLine(" ./tools/arti-build/build-arti.sh --target=$triple") appendLine(" ./tools/arti-build/build-arti.sh --target=$triple")
} }
append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.") append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.")
+10 -3
View File
@@ -135,6 +135,9 @@ cd tools/arti-build
# Build a single ABI without touching the other committed .so files # Build a single ABI without touching the other committed .so files
./build-arti.sh --target=armv7-linux-androideabi ./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 # Clean rebuild from scratch
./build-arti.sh --clean ./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 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` `amethyst/build.gradle.kts`, `targets` in `rust-toolchain.toml`, and `TARGETS`
in `build-arti.sh`. The `verifyArtiAbis` Gradle task (wired into `preBuild`) in `build-arti.sh`. (`verify-reproducible.sh` has no copy of its own — it asks
fails the build when an ABI split has no `libarti_android.so`, so the drift is `build-arti.sh --print-abis`, so it can never hash a different set than the one
caught here rather than on a user's phone. 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 ## Verifying 16KB page alignment
+18 -1
View File
@@ -19,6 +19,11 @@
# # add one ABI without rewriting the other .so # # add one ABI without rewriting the other .so
# # files already committed under jniLibs/. # # files already committed under jniLibs/.
# ./build-arti.sh --clean # Clean and rebuild # ./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 set -euo pipefail
@@ -75,19 +80,21 @@ REGEN_LOCK=false
# `${#empty_array[@]}` under `set -u` is an unbound-variable error. Target # `${#empty_array[@]}` under `set -u` is an unbound-variable error. Target
# triples never contain spaces, so word splitting is exact here. # triples never contain spaces, so word splitting is exact here.
SELECTED_TARGETS="" SELECTED_TARGETS=""
PRINT_ABIS=false
# Parse arguments # Parse arguments
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
--release) TARGETS=("aarch64-linux-android") ;; --release) TARGETS=("aarch64-linux-android") ;;
--target=*) SELECTED_TARGETS="$SELECTED_TARGETS ${arg#--target=}" ;; --target=*) SELECTED_TARGETS="$SELECTED_TARGETS ${arg#--target=}" ;;
--print-abis) PRINT_ABIS=true ;;
--clean) CLEAN=true ;; --clean) CLEAN=true ;;
# Refresh the committed Cargo.lock from the pinned Arti tag, then exit # Refresh the committed Cargo.lock from the pinned Arti tag, then exit
# (no compile — needs only git + cargo, not the NDK). Use after bumping # (no compile — needs only git + cargo, not the NDK). Use after bumping
# ARTI_VERSION / Cargo.toml; the normal build is --locked and will fail # ARTI_VERSION / Cargo.toml; the normal build is --locked and will fail
# until the lock is regenerated and committed. # until the lock is regenerated and committed.
--regen-lock) REGEN_LOCK=true; CLEAN=true ;; --regen-lock) REGEN_LOCK=true; CLEAN=true ;;
--help) echo "Usage: $0 [--release] [--target=<triple>]... [--clean] [--regen-lock] [--help]"; exit 0 ;; --help) echo "Usage: $0 [--release] [--target=<triple>]... [--clean] [--regen-lock] [--print-abis] [--help]"; exit 0 ;;
esac esac
done done
@@ -419,6 +426,16 @@ verify_ndk_stamp() {
# ============================================================================ # ============================================================================
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}" echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}"
# --regen-lock only needs git + cargo, not the NDK/cargo-ndk toolchain. # --regen-lock only needs git + cargo, not the NDK/cargo-ndk toolchain.
+8 -22
View File
@@ -30,33 +30,19 @@ sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi 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/ # Only the ABIs this run actually rebuilds. Hashing everything under jniLibs/
# (what `find` used to do) made `--release` look like it had verified the # (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 # 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 # identically in both runs and the script reported the whole tree reproducible
# and matching the commit. # and matching the commit.
ABIS="arm64-v8a x86_64 armeabi-v7a x86" #
SELECTED="" # Asked of build-arti.sh with the very flags it is about to receive, rather than
for arg in ${PASSTHRU[@]+"${PASSTHRU[@]}"}; do # kept as a second copy of the ABI list here. A local copy would drift the moment
case "$arg" in # an ABI is added: this script would rebuild it twice, hash neither, and still
--release) ABIS="arm64-v8a" ;; # print ✅ REPRODUCIBLE — the same false pass the paragraph above describes.
--target=*) SELECTED="$SELECTED $(abi_dir_for "${arg#--target=}")" ;; # Under `set -e` a failing --print-abis (e.g. an unknown triple) aborts here.
esac ABIS="$("$SCRIPT_DIR/build-arti.sh" --print-abis ${PASSTHRU[@]+"${PASSTHRU[@]}"} | tr '\n' ' ')"
done ABIS="${ABIS% }"
[ -n "$SELECTED" ] && ABIS="${SELECTED# }"
# sha256 of each built .so, keyed by ABI dir (relative paths → stable keys). # sha256 of each built .so, keyed by ABI dir (relative paths → stable keys).
hashes() { hashes() {