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 =
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<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(
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] ?: "<add the Rust target for $abi>"
appendLine("Rebuild them (tools/arti-build/README.md):")
problems.forEach { (abi, _) ->
val triple = expected[abi]?.first ?: "<add the Rust target for $abi>"
appendLine(" ./tools/arti-build/build-arti.sh --target=$triple")
}
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-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
+18 -1
View File
@@ -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=<triple>]... [--clean] [--regen-lock] [--help]"; exit 0 ;;
--help) echo "Usage: $0 [--release] [--target=<triple>]... [--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.
+8 -22
View File
@@ -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() {