//! 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 [ ...] //! 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 [ ...]", .{}); 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]; }