mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
194 lines
9.4 KiB
Python
194 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline APK/JNI export regressions using real, stripped ELF fixtures.
|
|
|
|
Requires a host C compiler and readelf; no Android SDK or device is needed.
|
|
The fixture functions test symbol visibility only, not the callbacks' ABI.
|
|
"""
|
|
import importlib.util
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
|
|
sys.dont_write_bytecode = True
|
|
spec = importlib.util.spec_from_file_location(
|
|
"native", Path(__file__).with_name("verify-android-native.py")
|
|
)
|
|
native = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(native)
|
|
|
|
ANDROID = Path(__file__).resolve().parents[1] / "platform/android"
|
|
APP_JAVA = ANDROID / "gradle/app/src/main/java/com/flectar/mail"
|
|
JAVA_PRIMITIVES = {
|
|
"boolean": "Z", "byte": "B", "char": "C", "short": "S",
|
|
"int": "I", "long": "J", "float": "F", "double": "D", "void": "V",
|
|
}
|
|
JAVA_STATEMENTS = {"if", "for", "while", "switch", "catch", "synchronized", "return", "new",
|
|
"throw", "else"}
|
|
JAVA_MODIFIERS = {"public", "protected", "private", "static", "final", "synchronized", "native"}
|
|
|
|
|
|
def java_descriptor(java_type, imports, app_classes):
|
|
"""The JNI descriptor of a type as a Java source file names it."""
|
|
dimensions = java_type.count("[]") + java_type.count("...")
|
|
name = java_type.replace("[]", "").replace("...", "").strip()
|
|
if name in JAVA_PRIMITIVES:
|
|
return "[" * dimensions + JAVA_PRIMITIVES[name]
|
|
outer, _, inner = name.partition(".")
|
|
if inner and outer[0].islower():
|
|
# Named with its package.
|
|
qualified = name
|
|
elif outer in imports:
|
|
qualified = imports[outer] + ("$" + inner.replace(".", "$") if inner else "")
|
|
elif outer in app_classes:
|
|
qualified = "com.flectar.mail." + name.replace(".", "$")
|
|
else:
|
|
# Java imports java.lang itself.
|
|
qualified = "java.lang." + name
|
|
return "[" * dimensions + "L" + qualified.replace(".", "/") + ";"
|
|
|
|
|
|
def java_methods():
|
|
"""The descriptor of each method the app's Java declares, with whether it is static."""
|
|
sources = {source.stem: source.read_text() for source in APP_JAVA.glob("*.java")}
|
|
methods = {}
|
|
for text in sources.values():
|
|
imports = {name: path for path, name in re.findall(r"^import\s+([\w.]+\.(\w+));", text, re.M)}
|
|
# Descriptors leave out type arguments.
|
|
while re.search(r"<[\w.?, \[\]]*>", text):
|
|
text = re.sub(r"<[\w.?, \[\]]*>", "", text)
|
|
for modifiers, returns, name, parameters in re.findall(
|
|
rf"^[ \t]*((?:(?:{'|'.join(JAVA_MODIFIERS)})\s+)*)"
|
|
r"([\w.$\[\]]+)\s+(\w+)\s*\(([^)]*)\)", text, re.M):
|
|
if returns in JAVA_STATEMENTS | JAVA_MODIFIERS:
|
|
continue
|
|
types = [re.sub(r"\s+\w+$", "", re.sub(r"@\w+\s+|\bfinal\s+", "", parameter.strip()))
|
|
for parameter in parameters.split(",") if parameter.strip()]
|
|
descriptor = ("(" + "".join(java_descriptor(t, imports, sources) for t in types) + ")"
|
|
+ java_descriptor(returns, imports, sources))
|
|
methods.setdefault(name, set()).add((descriptor, "static" in modifiers.split()))
|
|
return methods
|
|
|
|
|
|
def rust_calls_into_java():
|
|
"""Each method the Android crate calls by name, with its descriptor and whether it is static."""
|
|
calls = []
|
|
for source in sorted((ANDROID / "src").glob("*.rs")):
|
|
# A backslash at the end of a line continues a Rust string without the line break.
|
|
text = re.sub(r"\\\n\s*", "", source.read_text())
|
|
for match in re.finditer(r'"(\w+)",\s*"(\([^"]*\)[^"]*)"', text):
|
|
before = text[:match.start()]
|
|
static = before.rfind("call_static_method(") > before.rfind("call_method(")
|
|
calls.append((source.name, match[1], match[2], static))
|
|
# Helpers that take the method's name, as the settings ones do.
|
|
for helper, descriptor in re.findall(
|
|
r'fn (\w+)\(&self, class: &str, method: &str\)[^}]*?'
|
|
r'call_static_method\(\s*class,\s*method,\s*"([^"]+)"', text, re.S):
|
|
for name in re.findall(rf'\.{helper}\(\w+, "(\w+)"\)', text):
|
|
calls.append((source.name, name, descriptor, True))
|
|
return calls
|
|
|
|
|
|
class JniExportsTests(unittest.TestCase):
|
|
def build_library(self, directory, replacement=None, stripped=True):
|
|
source = Path(directory) / "fixture.c"
|
|
library = Path(directory) / "fixture.so"
|
|
source.write_text("\n".join(
|
|
replacement[1] if replacement and name == replacement[0]
|
|
else f"void {name}(void) {{}}"
|
|
for name in sorted(native.REQUIRED_EXPORTS)
|
|
))
|
|
command = ["cc", "-shared", "-fPIC", "-Wl,-z,max-page-size=16384"]
|
|
if stripped:
|
|
command.append("-s")
|
|
subprocess.run(command + [str(source), "-o", str(library)],
|
|
check=True, capture_output=True, text=True)
|
|
return library.read_bytes()
|
|
|
|
def test_every_native_declaration_is_required(self):
|
|
declared = {
|
|
f"Java_com_flectar_mail_{source.stem}_{method}"
|
|
for source in APP_JAVA.glob("*.java")
|
|
for method in re.findall(r"\bnative\s+\w+\s+(\w+)\s*\(", source.read_text())
|
|
}
|
|
required = {name for name in native.REQUIRED_EXPORTS if name.startswith("Java_")}
|
|
self.assertEqual(declared, required)
|
|
|
|
def test_rust_calls_find_their_java_methods(self):
|
|
# PR checks don't build the app, and ART reports a method it can't
|
|
# find only when a phone makes the call.
|
|
java = java_methods()
|
|
sources = "\n".join(source.read_text() for source in APP_JAVA.glob("*.java"))
|
|
declared = set(re.findall(r"\b(\w+)\s*\([^;{)]*\)\s*(?:throws\s+[\w.,\s]+)?\{",
|
|
sources)) - JAVA_STATEMENTS
|
|
checked = set()
|
|
for source, name, descriptor, static in rust_calls_into_java():
|
|
if name not in declared:
|
|
continue # an Android or Java method
|
|
with self.subTest(call=f"{source}: {name}{descriptor}"):
|
|
self.assertIn((descriptor, static), java.get(name, set()))
|
|
checked.add(name)
|
|
# Calls in strings across lines, through the settings helper and in
|
|
# every file are all read.
|
|
self.assertLessEqual({"show", "openSettings", "authorize", "chooseDocument"}, checked)
|
|
|
|
def test_release_stripping_preserves_required_dynamic_exports(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
data = self.build_library(directory)
|
|
native.verify_elf(data, "fixture.so")
|
|
native.verify_jni_exports(data, "fixture.so")
|
|
|
|
def test_each_missing_callback_and_entry_point_is_rejected(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
for name in sorted(native.REQUIRED_EXPORTS):
|
|
with self.subTest(symbol=name):
|
|
data = self.build_library(directory, (name, ""))
|
|
with self.assertRaisesRegex(ValueError, name):
|
|
native.verify_jni_exports(data, "fixture.so")
|
|
|
|
def test_non_callable_symbols_do_not_satisfy_the_contract(self):
|
|
name = "Java_com_flectar_mail_FlectarActivity_nativeGoogleAuthorizationResult"
|
|
variants = {
|
|
"hidden": f'__attribute__((visibility("hidden"))) void {name}(void) {{}}',
|
|
"local": f"static void {name}(void) {{}}",
|
|
"undefined": f"extern void {name}(void); void call_missing(void) {{ {name}(); }}",
|
|
"data": f"int {name} = 1;",
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
for kind, source in variants.items():
|
|
with self.subTest(kind=kind):
|
|
# Retain debug/static symbols to ensure they cannot mask a
|
|
# missing callable symbol in the dynamic table.
|
|
data = self.build_library(directory, (name, source), stripped=False)
|
|
with self.assertRaisesRegex(ValueError, name):
|
|
native.verify_jni_exports(data, "fixture.so")
|
|
|
|
def test_apk_checks_each_packaged_abi(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
complete = self.build_library(directory)
|
|
name = "Java_com_flectar_mail_FlectarActivity_nativeGoogleAuthorizationResult"
|
|
incomplete = self.build_library(directory, (name, ""))
|
|
apk = Path(directory) / "fixture.apk"
|
|
for second_library in (complete, incomplete):
|
|
with zipfile.ZipFile(apk, "w") as archive:
|
|
for abi, app in (("arm64-v8a", complete), ("x86_64", second_library)):
|
|
# Host ELF fixtures represent each packaging location;
|
|
# they are never executed as Android code.
|
|
archive.writestr(f"lib/{abi}/libflectar_mail_android.so", app)
|
|
archive.writestr(f"lib/{abi}/libpdfium.so", complete)
|
|
notices = f"assets/licenses/PDFium/{abi}/pdfium-licenses"
|
|
archive.writestr(f"{notices}/build.json", "{}")
|
|
archive.writestr(f"{notices}/LICENSE", "fixture")
|
|
if second_library == complete:
|
|
native.verify_apk(apk)
|
|
else:
|
|
with self.assertRaisesRegex(ValueError, f"x86_64/.*{name}"):
|
|
native.verify_apk(apk)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|