tally/build/audit_android.zig
Emil Lerch f12eb4e063
All checks were successful
Build / Android (push) Successful in 2m48s
Build / Engine, CLI and TUI (push) Successful in 7m18s
Build / Sign (push) Successful in 6m39s
Build / Publish (push) Successful in 17s
Build / F-Droid repo (push) Successful in 50s
integrate android audit into build flow
2026-10-13 08:57:33 -07:00

180 lines
8.1 KiB
Zig

//! Checks that each Android `libtally.so` will load on a device (design 6.3).
//!
//! The library links against nothing: no libc, no NDK. So every symbol it references
//! has to be one it defines, it may not have a TLS segment (only Bionic's libc can
//! satisfy `__tls_get_addr`), and it may not name a library it needs. Each of those
//! links on a desktop and fails in `dlopen` on a phone, before any code in the library
//! runs. The first APK failed exactly that way, and a later instance reproduced on
//! arm64-v8a alone, so an x86_64 emulator would have passed while phones failed. The
//! code that pulls such a symbol in never has to run for it to matter: `BIND_NOW` is
//! set, so even an unreferenced PLT entry is resolved at load.
//!
//! Also checked: every `LOAD` segment is aligned for 16 KiB pages, which Android 15
//! introduced and Play requires (a 4 KiB alignment loads on a 4 KiB device and is
//! refused at submission), the library is for the architecture its directory names,
//! and the JNI entry points are exported.
//!
//! This replaced `android/audit-libs.sh`, a `readelf` pipeline that CI ran as a step of
//! its own after the build, and that needed binutils installed to do it. Here it is part
//! of `zig build android`, on every machine that builds the libraries, and a library
//! that fails it is never installed for Gradle to package.
//!
//! Usage: audit-android <abi> <path/to/libtally.so> [<abi> <path> ...]
//! Prints nothing when every library passes; otherwise every problem found, and
//! exits non-zero.
const std = @import("std");
const elf = std.elf;
/// Android's page size since version 15, and what Play holds libraries to.
const page_alignment = 16 * 1024;
/// Every JNI entry point is named for the Kotlin class it belongs to.
const jni_prefix = "Java_dev_lerch_tally_TallyEngine_";
pub fn main(init: std.process.Init) !u8 {
const allocator = init.arena.allocator();
const args = try init.minimal.args.toSlice(allocator);
if (args.len < 3 or (args.len - 1) % 2 != 0) {
std.log.err("usage: audit-android <abi> <libtally.so> [<abi> <libtally.so> ...]", .{});
return 2;
}
var stderr_buf: [4096]u8 = undefined;
var stderr = std.Io.File.stderr().writerStreaming(init.io, &stderr_buf);
const out = &stderr.interface;
var failed = false;
var i: usize = 1;
while (i < args.len) : (i += 2) {
const abi = args[i];
const path = args[i + 1];
const bytes = std.Io.Dir.cwd().readFileAlloc(init.io, path, allocator, .limited(64 * 1024 * 1024)) catch |err| {
try out.print("{s}: cannot read {s}: {t}\n", .{ abi, path, err });
failed = true;
continue;
};
var report: Report = .{ .abi = abi, .out = out };
audit(allocator, abi, bytes, &report) catch |err| {
try out.print("{s}: {s} is not an ELF file this can read: {t}\n", .{ abi, path, err });
failed = true;
continue;
};
if (report.problems > 0) failed = true;
}
try out.flush();
return if (failed) 1 else 0;
}
/// Problems found in one library, written as they are found.
const Report = struct {
abi: []const u8,
out: *std.Io.Writer,
problems: usize = 0,
fn problem(self: *Report, comptime fmt: []const u8, args: anytype) !void {
self.problems += 1;
try self.out.print("{s}: " ++ fmt ++ "\n", .{self.abi} ++ args);
}
};
/// The ELF machine each Android ABI directory holds.
fn expectedMachine(abi: []const u8) ?elf.EM {
if (std.mem.eql(u8, abi, "arm64-v8a")) return .AARCH64;
if (std.mem.eql(u8, abi, "x86_64")) return .X86_64;
if (std.mem.eql(u8, abi, "armeabi-v7a")) return .ARM;
return null;
}
fn audit(allocator: std.mem.Allocator, abi: []const u8, bytes: []const u8, report: *Report) !void {
var reader: std.Io.Reader = .fixed(bytes);
const header = try elf.Header.read(&reader);
if (expectedMachine(abi)) |machine| {
if (header.machine != machine) try report.problem("built for {t}, not {t}", .{ header.machine, machine });
} else {
try report.problem("not an Android ABI this knows", .{});
}
// Program headers: no TLS, every LOAD aligned for 16 KiB pages, and where the
// dynamic section is.
var dynamic: ?struct { offset: u64, size: u64 } = null;
var phdrs = header.iterateProgramHeadersBuffer(bytes);
while (try phdrs.next()) |phdr| switch (phdr.p_type) {
elf.PT_TLS => try report.problem("has a TLS segment, which needs __tls_get_addr from a libc", .{}),
elf.PT_LOAD => if (phdr.p_align < page_alignment) {
try report.problem("a LOAD segment is aligned to 0x{x}, not the 0x{x} 16 KiB pages need", .{ phdr.p_align, page_alignment });
},
elf.PT_DYNAMIC => dynamic = .{ .offset = phdr.p_offset, .size = phdr.p_filesz },
else => {},
};
// The dynamic symbol table and its strings, from the section headers.
var dynsym: ?elf.Elf64_Shdr = null;
var dynstr: ?elf.Elf64_Shdr = null;
var sections = header.iterateSectionHeadersBuffer(bytes);
var all_sections: std.ArrayList(elf.Elf64_Shdr) = .empty;
while (try sections.next()) |shdr| try all_sections.append(allocator, shdr);
for (all_sections.items) |shdr| {
if (shdr.sh_type == elf.SHT_DYNSYM) {
dynsym = shdr;
if (shdr.sh_link < all_sections.items.len) dynstr = all_sections.items[shdr.sh_link];
}
}
const symbols = dynsym orelse return report.problem("has no dynamic symbol table, so it exports nothing", .{});
const strings = dynstr orelse return report.problem("has no string table for its dynamic symbols", .{});
const names = try slice(bytes, strings.sh_offset, strings.sh_size);
// Dynamic entries: no DT_NEEDED.
if (dynamic) |d| {
var entries = header.iterateDynamicSectionBuffer(bytes, d.offset, d.size);
while (try entries.next()) |entry| {
if (entry.d_tag == elf.DT_NULL) break;
if (entry.d_tag == elf.DT_NEEDED) {
try report.problem("needs {s}, and it may need no library", .{name(names, entry.d_val)});
}
}
}
// Symbols: none undefined, and the JNI entry points defined.
const entry_size: u64 = if (header.is_64) @sizeOf(elf.Elf64_Sym) else @sizeOf(elf.Elf32_Sym);
const table = try slice(bytes, symbols.sh_offset, symbols.sh_size);
var jni_exports: usize = 0;
var has_onload = false;
var at: usize = 0;
while (at + entry_size <= table.len) : (at += @intCast(entry_size)) {
var sym_reader: std.Io.Reader = .fixed(table[at..]);
const sym_name: u32, const section: u16 = if (header.is_64) blk: {
const sym = try sym_reader.takeStruct(elf.Elf64_Sym, header.endian);
break :blk .{ sym.st_name, sym.st_shndx };
} else blk: {
const sym = try sym_reader.takeStruct(elf.Elf32_Sym, header.endian);
break :blk .{ sym.st_name, sym.st_shndx };
};
const text = name(names, sym_name);
// The first entry is the null symbol, which has no name and is not a reference.
if (text.len == 0) continue;
if (section == elf.SHN_UNDEF) {
try report.problem("references {s} and does not define it", .{text});
} else if (std.mem.startsWith(u8, text, jni_prefix)) {
jni_exports += 1;
} else if (std.mem.eql(u8, text, "JNI_OnLoad")) {
has_onload = true;
}
}
if (jni_exports == 0) try report.problem("exports no {s}* entry points", .{jni_prefix});
if (!has_onload) try report.problem("does not export JNI_OnLoad", .{});
}
/// `len` bytes at `offset`, or an error if the file is shorter than its headers say.
fn slice(bytes: []const u8, offset: u64, len: u64) error{EndOfStream}![]const u8 {
if (offset > bytes.len or len > bytes.len - offset) return error.EndOfStream;
return bytes[@intCast(offset)..][0..@intCast(len)];
}
/// The NUL-terminated string at `offset` in a string table.
fn name(strings: []const u8, offset: u64) []const u8 {
if (offset >= strings.len) return "";
const rest = strings[@intCast(offset)..];
return rest[0 .. std.mem.indexOfScalar(u8, rest, 0) orelse rest.len];
}