From 02b25ea71d95397b0e87dbd895b22de530cdd6ec Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:15:00 +0530 Subject: [PATCH 1/5] Add GrapheneOS Zygote contract fixtures --- .../grapheneos_zygote_contract/baseline.json | 63 +++++++++++++++++++ .../com/android/internal/os/ExecSpawning.java | 11 ++++ .../android/internal/os/ZygoteConnection.java | 17 +++++ .../android/internal/os/ZygoteExtraArgs.java | 32 ++++++++++ .../jni/com_android_internal_os_Zygote.cpp | 44 +++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 tests/fixtures/grapheneos_zygote_contract/baseline.json create mode 100644 tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ExecSpawning.java create mode 100644 tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteConnection.java create mode 100644 tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteExtraArgs.java create mode 100644 tests/fixtures/grapheneos_zygote_contract/sources/core/jni/com_android_internal_os_Zygote.cpp diff --git a/tests/fixtures/grapheneos_zygote_contract/baseline.json b/tests/fixtures/grapheneos_zygote_contract/baseline.json new file mode 100644 index 0000000000000..2a7966510a086 --- /dev/null +++ b/tests/fixtures/grapheneos_zygote_contract/baseline.json @@ -0,0 +1,63 @@ +{ + "canonical_sha256": "a19bec7b1ff9fb0b8211fdfc777e5e8c15a6e386c9fa84cc2b52d94101f26829", + "extra_long_args": { + "java_indices": { + "ARR_LEN": 2, + "IDX_FLAGS": 1, + "IDX_SELINUX_FLAGS": 0 + }, + "java_make_jni_long_array": [ + [ + "IDX_SELINUX_FLAGS", + "selinuxFlags" + ], + [ + "IDX_FLAGS", + "flags" + ] + ], + "native_forcibly_enable_memory_tagging": { + "expression": "1 << 2", + "value": 4 + }, + "native_jlong_array_order": [ + [ + "selinux_flags", + "0" + ], + [ + "flags", + "1" + ] + ], + "should_use_exec_spawning": "return !hasFlag(Flag.USE_ZYGOTE_SPAWNING);", + "use_zygote_spawning": { + "expression": "1 << 3", + "value": 8 + } + }, + "jni_descriptors": { + "nativeForkAndSpecialize": "([JII[II[[IILjava/lang/String;Ljava/lang/String;[I[IZLjava/lang/String;Ljava/lang/String;ZZ[Ljava/lang/String;[Ljava/lang/String;ZZZ)I", + "nativeForkSystemServer": "(II[II[[IJJ)I", + "nativeSpecializeAppProcess": "([JII[II[[IILjava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Z[Ljava/lang/String;[Ljava/lang/String;ZZZ)V" + }, + "replay": { + "fds_to_close_guarded_assignments": [ + 0, + 1 + ], + "fds_to_close_initial_sentinel": [ + -1, + -1 + ], + "fds_to_close_socket_fill_guard": "!isReplayingZygoteCommands", + "native_detach_exec_sentinel": "for (int fd : fds_to_close) { if (fd == -1 && gIsExecSpawning) { continue; }", + "native_fork_expression": "pid_t pid = gIsExecSpawning ? 0 : fork();", + "replay_process_command_call": "processCommand(zygoteServer, false, cmd)", + "replay_process_command_multiple_ok": false + }, + "upstream": { + "branch": "17", + "repository": "GrapheneOS/platform_frameworks_base" + } +} diff --git a/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ExecSpawning.java b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ExecSpawning.java new file mode 100644 index 0000000000000..b59ca19360be0 --- /dev/null +++ b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ExecSpawning.java @@ -0,0 +1,11 @@ +package com.android.internal.os; + +class ExecSpawning { + static boolean isReplayingZygoteCommands() { return true; } + + static Runnable replay(ZygoteConnection pseudoConnection, ZygoteServer zygoteServer, + ZygoteArguments cmd) { + Runnable r = pseudoConnection.processCommand(zygoteServer, false, cmd); + return r; + } +} diff --git a/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteConnection.java b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteConnection.java new file mode 100644 index 0000000000000..4474b84229857 --- /dev/null +++ b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteConnection.java @@ -0,0 +1,17 @@ +package com.android.internal.os; + +class ZygoteConnection { + Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK, ZygoteArguments command) { + boolean isReplayingZygoteCommands = ExecSpawning.isReplayingZygoteCommands(); + int [] fdsToClose = { -1, -1 }; + if (!isReplayingZygoteCommands) { + FileDescriptor fd = mSocket.getFileDescriptor(); + if (fd != null) { fdsToClose[0] = fd.getInt$(); } + FileDescriptor zygoteFd = zygoteServer.getZygoteSocketFileDescriptor(); + if (zygoteFd != null) { fdsToClose[1] = zygoteFd.getInt$(); } + } + boolean shouldUseExecSpawning = !isReplayingZygoteCommands + && parsedArgs.mExtraArgs.shouldUseExecSpawning(); + return null; + } +} diff --git a/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteExtraArgs.java b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteExtraArgs.java new file mode 100644 index 0000000000000..4c1333ecf5e15 --- /dev/null +++ b/tests/fixtures/grapheneos_zygote_contract/sources/core/java/com/android/internal/os/ZygoteExtraArgs.java @@ -0,0 +1,32 @@ +package com.android.internal.os; + +public class ZygoteExtraArgs { + public interface Flag { + int DISABLE_HARDENED_MALLOC = 1; + int ENABLE_COMPAT_VA_39_BIT = 1 << 1; + int FORCIBLY_ENABLE_MEMORY_TAGGING = 1 << 2; + int USE_ZYGOTE_SPAWNING = 1 << 3; + int PREFER_COMPAT_ZYGOTE = 1 << 4; + int MANUALLY_RUN_ZYGOTE_PRELOAD = 1 << 5; + } + + private long selinuxFlags; + private int flags; + + public boolean shouldUseExecSpawning() { + return !hasFlag(Flag.USE_ZYGOTE_SPAWNING); + } + + public boolean hasFlag(int flag) { return (flags & flag) == flag; } + + private static final int IDX_SELINUX_FLAGS = 0; + private static final int IDX_FLAGS = 1; + private static final int ARR_LEN = 2; + + public long[] makeJniLongArray() { + long[] res = new long[ARR_LEN]; + res[IDX_SELINUX_FLAGS] = selinuxFlags; + res[IDX_FLAGS] = flags; + return res; + } +} diff --git a/tests/fixtures/grapheneos_zygote_contract/sources/core/jni/com_android_internal_os_Zygote.cpp b/tests/fixtures/grapheneos_zygote_contract/sources/core/jni/com_android_internal_os_Zygote.cpp new file mode 100644 index 0000000000000..efd029a68ac9c --- /dev/null +++ b/tests/fixtures/grapheneos_zygote_contract/sources/core/jni/com_android_internal_os_Zygote.cpp @@ -0,0 +1,44 @@ +namespace ExtraArgsFlag { + static const int FORCIBLY_ENABLE_MEMORY_TAGGING = 1 << 2; +} + +struct ExtraArgs { + uint64_t selinux_flags = 0; + int flags = 0; + + ExtraArgs(JNIEnv* env, jlongArray jlongArgs) { + const size_t num_jlong_args = 2; + jlong jlong_arr[num_jlong_args]; + env->GetLongArrayRegion(jlongArgs, 0, num_jlong_args, (jlong *) &jlong_arr); + selinux_flags = (uint64_t) jlong_arr[0]; + flags = (int) jlong_arr[1]; + } +}; + +static bool gIsExecSpawning = false; + +static void DetachDescriptors(JNIEnv* env, const std::vector& fds_to_close, fail_fn_t fail_fn) { + for (int fd : fds_to_close) { + if (fd == -1 && gIsExecSpawning) { + continue; + } + } +} + +static pid_t ForkCommon() { + pid_t pid = gIsExecSpawning ? 0 : fork(); + return pid; +} + +static const JNINativeMethod gMethods[] = { + {"nativeForkAndSpecialize", + "([JII[II[[IILjava/lang/String;Ljava/lang/String;[I[IZLjava/lang/String;Ljava/lang/" + "String;ZZ[Ljava/lang/String;[Ljava/lang/String;ZZZ)I", + (void*)com_android_internal_os_Zygote_nativeForkAndSpecialize}, + {"nativeForkSystemServer", "(II[II[[IJJ)I", + (void*)com_android_internal_os_Zygote_nativeForkSystemServer}, + {"nativeSpecializeAppProcess", + "([JII[II[[IILjava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/" + "String;Z[Ljava/lang/String;[Ljava/lang/String;ZZZ)V", + (void*)com_android_internal_os_Zygote_nativeSpecializeAppProcess}, +}; From f4f2bed8b66af367ec9ab37a3057d70856a1ddd6 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:15:08 +0530 Subject: [PATCH 2/5] Add GrapheneOS Zygote contract monitor --- scripts/grapheneos_zygote_contract.py | 287 +++++++++++++++++++++++ tests/test_grapheneos_zygote_contract.py | 161 +++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 scripts/grapheneos_zygote_contract.py create mode 100644 tests/test_grapheneos_zygote_contract.py diff --git a/scripts/grapheneos_zygote_contract.py b/scripts/grapheneos_zygote_contract.py new file mode 100644 index 0000000000000..a38c57c8d8b66 --- /dev/null +++ b/scripts/grapheneos_zygote_contract.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +REPOSITORY = "GrapheneOS/platform_frameworks_base" +BRANCH = "17" +API_URL = f"https://api.github.com/repos/{REPOSITORY}/commits/{BRANCH}" +RAW_BASE_URL = f"https://raw.githubusercontent.com/{REPOSITORY}" +USER_AGENT = "Magisk GrapheneOS zygote contract monitor/1" +MAX_SOURCE_BYTES = 2 * 1024 * 1024 +SOURCE_PATHS = ( + "core/java/com/android/internal/os/ZygoteConnection.java", + "core/java/com/android/internal/os/ZygoteExtraArgs.java", + "core/java/com/android/internal/os/ExecSpawning.java", + "core/jni/com_android_internal_os_Zygote.cpp", +) +JNI_METHODS = ( + "nativeForkAndSpecialize", + "nativeForkSystemServer", + "nativeSpecializeAppProcess", +) + + +class ContractError(RuntimeError): + pass + + +def compact(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def require(pattern: str, text: str, field: str, flags: int = re.S) -> re.Match[str]: + match = re.search(pattern, text, flags) + if match is None: + raise ContractError(f"missing GrapheneOS zygote contract field: {field}") + return match + + +def strip_comments(text: str) -> str: + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"//.*", "", text) + + +def int_expr(expr: str) -> int: + clean = expr.replace(" ", "") + match = re.fullmatch(r"(\d+)(?:<<(\d+))?", clean) + if match is None: + raise ContractError(f"unsupported integer expression: {expr}") + value = int(match.group(1)) + return value << int(match.group(2) or "0") + + +def read_response(url: str, timeout: float, max_bytes: int) -> bytes: + request = Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urlopen(request, timeout=timeout) as response: + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > max_bytes: + raise ContractError(f"response exceeds {max_bytes} bytes: {url}") + data = response.read(max_bytes + 1) + except (HTTPError, URLError, TimeoutError, OSError, ValueError) as exc: + raise ContractError(f"failed to fetch {url}: {exc}") from exc + if len(data) > max_bytes: + raise ContractError(f"response exceeds {max_bytes} bytes: {url}") + return data + + +def fetch_sources(timeout: float) -> tuple[dict[str, str], str]: + try: + revision = json.loads(read_response(API_URL, timeout, MAX_SOURCE_BYTES))["sha"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise ContractError(f"failed to resolve GrapheneOS branch {BRANCH}: {exc}") from exc + if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ContractError(f"invalid GrapheneOS revision: {revision!r}") + + sources: dict[str, str] = {} + for path in SOURCE_PATHS: + try: + sources[path] = read_response( + f"{RAW_BASE_URL}/{revision}/{path}", timeout, MAX_SOURCE_BYTES + ).decode("utf-8") + except UnicodeDecodeError as exc: + raise ContractError(f"failed to decode {path}: {exc}") from exc + return sources, revision + + +def read_sources(source_dir: Path) -> dict[str, str]: + sources: dict[str, str] = {} + for path in SOURCE_PATHS: + file_path = source_dir / path + try: + sources[path] = file_path.read_text(encoding="utf-8") + except OSError as exc: + raise ContractError(f"failed to read {file_path}: {exc}") from exc + return sources + + +def extract_jni_descriptors(native: str) -> dict[str, str]: + table = require(r"static\s+const\s+JNINativeMethod\s+gMethods\[\]\s*=\s*\{(?P.*?)\};", native, "JNI table").group("body") + descriptors: dict[str, str] = {} + for name in JNI_METHODS: + pattern = r'\{"' + re.escape(name) + r'"\s*,\s*(?P(?:"[^"]*"\s*)+)\s*,\s*\(void\*\)' + parts = require(pattern, table, f"JNI descriptor {name}").group("parts") + descriptors[name] = "".join(re.findall(r'"([^"]*)"', parts)) + return descriptors + + +def braced_block(pattern: str, text: str, field: str) -> str: + match = require(pattern, text, field, 0) + start = text.find("{", match.end()) + if start == -1: + raise ContractError(f"missing opening brace for {field}") + depth = 0 + for index in range(start, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start + 1 : index] + raise ContractError(f"missing closing brace for {field}") + + +def extract_extra_args(extra: str, native: str) -> dict[str, object]: + clean_extra = strip_comments(extra) + use_expr = require(r"int\s+USE_ZYGOTE_SPAWNING\s*=\s*([^;]+);", clean_extra, "USE_ZYGOTE_SPAWNING").group(1) + selinux_idx = require(r"IDX_SELINUX_FLAGS\s*=\s*(\d+)", clean_extra, "IDX_SELINUX_FLAGS").group(1) + flags_idx = require(r"IDX_FLAGS\s*=\s*(\d+)", clean_extra, "IDX_FLAGS").group(1) + arr_len = require(r"ARR_LEN\s*=\s*(\d+)", clean_extra, "ARR_LEN").group(1) + make_body = require(r"long\[\]\s+res\s*=\s*new\s+long\[ARR_LEN\];(?P.*?)return\s+res\s*;", clean_extra, "makeJniLongArray").group("body") + native_body = require(r"ExtraArgs\(JNIEnv\* env, jlongArray jlongArgs\).*?\{(?P.*?)\n\s*\}", native, "native ExtraArgs constructor").group("body") + native_flag = require(r"FORCIBLY_ENABLE_MEMORY_TAGGING\s*=\s*([^;]+);", native, "native ExtraArgs flag").group(1) + java_order = [ + list(item) for item in re.findall(r"res\[(IDX_[A-Z_]+)\]\s*=\s*([A-Za-z0-9_.$]+)", make_body) + ] + expected_java_order = [["IDX_SELINUX_FLAGS", "selinuxFlags"], ["IDX_FLAGS", "flags"]] + if java_order != expected_java_order: + raise ContractError(f"unexpected Java extra-long-args order: {java_order}") + + native_order = [ + list(item) for item in re.findall(r"(selinux_flags|flags)\s*=.*?jlong_arr\[(\d+)\]", native_body) + ] + expected_native_order = [["selinux_flags", "0"], ["flags", "1"]] + if native_order != expected_native_order: + raise ContractError(f"unexpected native extra-long-args order: {native_order}") + + exec_body = compact(braced_block(r"boolean\s+shouldUseExecSpawning\s*\(\s*\)", clean_extra, "shouldUseExecSpawning")) + if exec_body != "return !hasFlag(Flag.USE_ZYGOTE_SPAWNING);": + raise ContractError(f"unexpected shouldUseExecSpawning contract: {exec_body}") + + return { + "use_zygote_spawning": {"expression": compact(use_expr), "value": int_expr(use_expr)}, + "should_use_exec_spawning": exec_body, + "java_indices": {"IDX_SELINUX_FLAGS": int(selinux_idx), "IDX_FLAGS": int(flags_idx), "ARR_LEN": int(arr_len)}, + "java_make_jni_long_array": java_order, + "native_jlong_array_order": native_order, + "native_forcibly_enable_memory_tagging": {"expression": compact(native_flag), "value": int_expr(native_flag)}, + } + + +def extract_replay_contract(connection: str, exec_spawning: str, native: str) -> dict[str, object]: + clean_conn = strip_comments(connection) + fds = require(r"int\s*\[\]\s*fdsToClose\s*=\s*\{\s*(-?\d+)\s*,\s*(-?\d+)\s*\};", clean_conn, "fdsToClose replay sentinel") + guard_body = braced_block(r"if\s*\(\s*!isReplayingZygoteCommands\s*\)", clean_conn[fds.end() :], "fdsToClose replay guard") + assignments = sorted( + int(index) + for index in re.findall(r"fdsToClose\s*\[\s*([01])\s*\]\s*=\s*(?:fd|zygoteFd)\.getInt\$\(\s*\)", guard_body) + ) + if assignments != [0, 1]: + raise ContractError(f"unexpected guarded fdsToClose assignments: {assignments}") + replay_call = require(r"processCommand\(\s*zygoteServer\s*,\s*false\s*,\s*cmd\s*\)", exec_spawning, "replay processCommand false") + detach = require(r"for\s*\(int\s+fd\s*:\s*fds_to_close\).*?if\s*\(\s*fd\s*==\s*-1\s*&&\s*gIsExecSpawning\s*\)\s*\{\s*continue\s*;\s*\}", native, "native fds_to_close -1 exec sentinel") + fork = require(r"pid_t\s+pid\s*=\s*gIsExecSpawning\s*\?\s*0\s*:\s*fork\s*\(\s*\)\s*;", native, "gIsExecSpawning fork behavior") + return { + "fds_to_close_initial_sentinel": [int(fds.group(1)), int(fds.group(2))], + "fds_to_close_socket_fill_guard": "!isReplayingZygoteCommands", + "fds_to_close_guarded_assignments": assignments, + "replay_process_command_multiple_ok": False, + "replay_process_command_call": compact(replay_call.group(0)), + "native_detach_exec_sentinel": compact(detach.group(0)), + "native_fork_expression": compact(fork.group(0)), + } + + +def extract_contract(sources: dict[str, str], source_revision: str | None = None) -> dict[str, object]: + native = sources["core/jni/com_android_internal_os_Zygote.cpp"] + contract = { + "upstream": {"repository": REPOSITORY, "branch": BRANCH}, + "jni_descriptors": extract_jni_descriptors(native), + "extra_long_args": extract_extra_args(sources["core/java/com/android/internal/os/ZygoteExtraArgs.java"], native), + "replay": extract_replay_contract( + sources["core/java/com/android/internal/os/ZygoteConnection.java"], + sources["core/java/com/android/internal/os/ExecSpawning.java"], + native, + ), + } + if source_revision is not None: + contract["source_revision"] = source_revision + contract["canonical_sha256"] = canonical_hash(contract) + return contract + + +def canonical_json(contract: dict[str, object]) -> str: + semantic = { + key: value + for key, value in contract.items() + if key not in {"canonical_sha256", "source_revision"} + } + return json.dumps(semantic, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def canonical_hash(contract: dict[str, object]) -> str: + return hashlib.sha256(canonical_json(contract).encode()).hexdigest() + + +def write_json(path: Path, contract: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(contract, sort_keys=True, indent=2) + "\n", encoding="utf-8") + + +def markdown_report(current: dict[str, object], baseline: dict[str, object] | None) -> str: + lines = ["", "# GrapheneOS Zygote contract drift", ""] + lines.append(f"Current hash: `{current['canonical_sha256']}`") + if "source_revision" in current: + lines.append(f"Source revision: `{current['source_revision']}`") + if baseline is None: + lines.append("Baseline: missing") + else: + lines.append(f"Baseline hash: `{baseline.get('canonical_sha256', '')}`") + if canonical_hash(current) == canonical_hash(baseline): + lines.append("\nNo semantic contract drift detected.") + else: + lines.append("\nSemantic contract drift detected. Review `grapheneos_zygote_contract_current.json`.") + lines.append("\nMonitored fields: JNI descriptors, extra-long-args indices/flags, replay fds sentinel, replay `processCommand(..., false, ...)`, and exec-spawn fork behavior.") + return "\n".join(lines) + "\n" + + +def run(source_dir: Path | None, baseline_path: Path, out_json: Path, report: Path, timeout: float) -> int: + if source_dir: + sources = read_sources(source_dir) + revision = None + else: + sources, revision = fetch_sources(timeout) + current = extract_contract(sources, revision) + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) if baseline_path.exists() else None + write_json(out_json, current) + report.write_text(markdown_report(current, baseline), encoding="utf-8") + return 0 if baseline is not None and canonical_hash(current) == canonical_hash(baseline) else 2 + + +def main_with_args_for_test(source_dir: Path, baseline: Path, out_json: Path, report: Path) -> int: + return run(source_dir, baseline, out_json, report, 20.0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check GrapheneOS zygote semantic contract drift.") + parser.add_argument("--source-dir", type=Path) + parser.add_argument("--baseline", type=Path, default=Path("tests/fixtures/grapheneos_zygote_contract/baseline.json")) + parser.add_argument("--out-json", type=Path, default=Path("grapheneos_zygote_contract_current.json")) + parser.add_argument("--report", type=Path, default=Path("grapheneos_zygote_contract_report.md")) + parser.add_argument("--timeout", type=float, default=20.0) + args = parser.parse_args() + try: + return run(args.source_dir, args.baseline, args.out_json, args.report, args.timeout) + except ContractError as exc: + message = f"error: {exc}" + print(message, file=sys.stderr) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text( + "\n" + "# GrapheneOS Zygote contract extraction failed\n\n" + f"`{message}`\n", + encoding="utf-8", + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_grapheneos_zygote_contract.py b/tests/test_grapheneos_zygote_contract.py new file mode 100644 index 0000000000000..19ae4b593644e --- /dev/null +++ b/tests/test_grapheneos_zygote_contract.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts import grapheneos_zygote_contract as contract + +FIXTURE = Path(__file__).parent / "fixtures" / "grapheneos_zygote_contract" +SOURCES = FIXTURE / "sources" +BASELINE = FIXTURE / "baseline.json" + + +class GrapheneOsZygoteContractTest(unittest.TestCase): + def test_extract_contract_from_offline_fixture(self) -> None: + current = contract.extract_contract(contract.read_sources(SOURCES)) + + self.assertEqual(current, json.loads(BASELINE.read_text(encoding="utf-8"))) + + def test_formatting_tolerance_for_jni_descriptor_fragments(self) -> None: + native = (SOURCES / "core/jni/com_android_internal_os_Zygote.cpp").read_text(encoding="utf-8") + native = native.replace('"nativeForkSystemServer", "(II[II[[IJJ)I"', '"nativeForkSystemServer",\n "(II[II[[IJJ)I"') + sources = contract.read_sources(SOURCES) + sources["core/jni/com_android_internal_os_Zygote.cpp"] = native + + current = contract.extract_contract(sources) + + self.assertEqual(current["canonical_sha256"], "a19bec7b1ff9fb0b8211fdfc777e5e8c15a6e386c9fa84cc2b52d94101f26829") + + def test_missing_field_fails_loudly(self) -> None: + sources = contract.read_sources(SOURCES) + sources["core/java/com/android/internal/os/ZygoteExtraArgs.java"] = sources[ + "core/java/com/android/internal/os/ZygoteExtraArgs.java" + ].replace("int USE_ZYGOTE_SPAWNING = 1 << 3;", "") + + with self.assertRaisesRegex(contract.ContractError, "USE_ZYGOTE_SPAWNING"): + contract.extract_contract(sources) + + def test_partial_extra_args_order_fails_loudly(self) -> None: + sources = contract.read_sources(SOURCES) + sources["core/java/com/android/internal/os/ZygoteExtraArgs.java"] = sources[ + "core/java/com/android/internal/os/ZygoteExtraArgs.java" + ].replace("res[IDX_FLAGS] = flags;", "") + + with self.assertRaisesRegex(contract.ContractError, "Java extra-long-args order"): + contract.extract_contract(sources) + + def test_exec_spawning_semantic_flip_fails_loudly(self) -> None: + sources = contract.read_sources(SOURCES) + sources["core/java/com/android/internal/os/ZygoteExtraArgs.java"] = sources[ + "core/java/com/android/internal/os/ZygoteExtraArgs.java" + ].replace("return !hasFlag(Flag.USE_ZYGOTE_SPAWNING);", "return hasFlag(Flag.USE_ZYGOTE_SPAWNING);") + + with self.assertRaisesRegex(contract.ContractError, "shouldUseExecSpawning contract"): + contract.extract_contract(sources) + + def test_socket_assignment_outside_replay_guard_fails_loudly(self) -> None: + sources = contract.read_sources(SOURCES) + connection = sources["core/java/com/android/internal/os/ZygoteConnection.java"] + connection = connection.replace( + "if (fd != null) { fdsToClose[0] = fd.getInt$(); }", + "if (fd != null) { }", + ).replace( + "boolean shouldUseExecSpawning", + "fdsToClose[0] = fd.getInt$();\n boolean shouldUseExecSpawning", + ) + sources["core/java/com/android/internal/os/ZygoteConnection.java"] = connection + + with self.assertRaisesRegex(contract.ContractError, "guarded fdsToClose assignments"): + contract.extract_contract(sources) + + def test_canonical_hash_is_stable_under_key_order(self) -> None: + current = contract.extract_contract(contract.read_sources(SOURCES)) + shuffled = {"upstream": current["upstream"], "replay": current["replay"], "jni_descriptors": current["jni_descriptors"], "extra_long_args": current["extra_long_args"]} + + self.assertEqual(contract.canonical_hash(shuffled), current["canonical_sha256"]) + + def test_source_revision_is_not_semantic_drift(self) -> None: + sources = contract.read_sources(SOURCES) + first = contract.extract_contract(sources, "a" * 40) + second = contract.extract_contract(sources, "b" * 40) + + self.assertEqual(contract.canonical_hash(first), contract.canonical_hash(second)) + + def test_drift_report_names_current_and_baseline_hashes(self) -> None: + current = contract.extract_contract(contract.read_sources(SOURCES)) + baseline = json.loads(BASELINE.read_text(encoding="utf-8")) + baseline["replay"]["fds_to_close_initial_sentinel"] = [0, -1] + baseline["canonical_sha256"] = contract.canonical_hash(baseline) + + report = contract.markdown_report(current, baseline) + + self.assertIn("", report) + self.assertIn("Semantic contract drift detected", report) + self.assertIn(current["canonical_sha256"], report) + self.assertIn(baseline["canonical_sha256"], report) + + def test_cli_returns_zero_for_match_and_two_for_drift(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + out_json = tmp_path / "current.json" + report = tmp_path / "report.md" + self.assertEqual(contract.main_with_args_for_test(SOURCES, BASELINE, out_json, report), 0) + drift = tmp_path / "drift.json" + shutil.copyfile(BASELINE, drift) + data = json.loads(drift.read_text(encoding="utf-8")) + data["upstream"]["branch"] = "drift" + drift.write_text(json.dumps(data), encoding="utf-8") + self.assertEqual(contract.main_with_args_for_test(SOURCES, drift, out_json, report), 2) + + def test_cli_writes_report_when_extraction_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources = tmp_path / "sources" + shutil.copytree(SOURCES, sources) + extra_args = sources / "core/java/com/android/internal/os/ZygoteExtraArgs.java" + extra_args.write_text( + extra_args.read_text(encoding="utf-8").replace( + "int USE_ZYGOTE_SPAWNING = 1 << 3;", "" + ), + encoding="utf-8", + ) + report = tmp_path / "report.md" + argv = [ + "grapheneos_zygote_contract.py", + "--source-dir", + str(sources), + "--baseline", + str(BASELINE), + "--out-json", + str(tmp_path / "current.json"), + "--report", + str(report), + ] + + with mock.patch.object(sys, "argv", argv): + self.assertEqual(contract.main(), 1) + + self.assertIn("contract extraction failed", report.read_text(encoding="utf-8")) + + def test_workflow_handles_empty_issue_bodies_and_uploads_failure_report(self) -> None: + workflow = ( + Path(__file__).parents[1] + / ".github/workflows/grapheneos_zygote_contract_monitor.yml" + ).read_text(encoding="utf-8") + + self.assertIn('(.body // \\"\\") | contains', workflow) + self.assertIn("if: always()", workflow) + self.assertIn("if-no-files-found: warn", workflow) + self.assertIn( + 'elif [ -n "$issue_number" ] && [ "$issue_state" = "OPEN" ]; then', + workflow, + ) + + +if __name__ == "__main__": + unittest.main() From 99c27ca52338340822e40403ed8d2dba854d264b Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:15:16 +0530 Subject: [PATCH 3/5] Run GrapheneOS Zygote contract monitor weekly --- .../grapheneos_zygote_contract_monitor.yml | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/grapheneos_zygote_contract_monitor.yml diff --git a/.github/workflows/grapheneos_zygote_contract_monitor.yml b/.github/workflows/grapheneos_zygote_contract_monitor.yml new file mode 100644 index 0000000000000..c78f27a1d23ac --- /dev/null +++ b/.github/workflows/grapheneos_zygote_contract_monitor.yml @@ -0,0 +1,80 @@ +name: GrapheneOS Zygote Contract Monitor + +on: + workflow_dispatch: + schedule: + - cron: "17 4 * * 1" + +permissions: + contents: read + issues: write + +concurrency: + group: grapheneos-zygote-contract-monitor + cancel-in-progress: false + +jobs: + monitor: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Check GrapheneOS zygote contract + id: monitor + run: | + set +e + python3 scripts/grapheneos_zygote_contract.py \ + --out-json grapheneos_zygote_contract_current.json \ + --report grapheneos_zygote_contract_report.md + status=$? + set -e + if [ "$status" = "0" ]; then + echo "drift=false" >> "$GITHUB_OUTPUT" + elif [ "$status" = "2" ]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + else + exit "$status" + fi + + - name: Upload contract report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: grapheneos-zygote-contract + if-no-files-found: warn + path: | + grapheneos_zygote_contract_current.json + grapheneos_zygote_contract_report.md + + - name: Sync drift issue + if: github.repository == 'pixincreate/Magisk' + env: + GH_TOKEN: ${{ github.token }} + DRIFT: ${{ steps.monitor.outputs.drift }} + MARKER: "" + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + issue="$(gh issue list --state all --limit 1000 --json number,body,title,state,author --jq "map(select(.title == \"GrapheneOS zygote semantic contract drift\" and .author.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$MARKER\")))) | sort_by(.number) | last | if . then [.number, .state] | @tsv else \"\" end")" + issue_number="${issue%%$'\t'*}" + issue_state="${issue#*$'\t'}" + if [ "$DRIFT" = "true" ]; then + { + cat grapheneos_zygote_contract_report.md + printf '\nWorkflow run: %s\n' "$RUN_URL" + } > issue_body.md + if [ -n "$issue_number" ]; then + if [ "$issue_state" = "CLOSED" ]; then + gh issue reopen "$issue_number" + fi + gh issue edit "$issue_number" --title "GrapheneOS zygote semantic contract drift" --body-file issue_body.md + else + gh issue create --title "GrapheneOS zygote semantic contract drift" --body-file issue_body.md + fi + elif [ -n "$issue_number" ] && [ "$issue_state" = "OPEN" ]; then + gh issue comment "$issue_number" --body "GrapheneOS zygote contract matches the committed baseline again. Closing from $RUN_URL." + gh issue close "$issue_number" --reason completed + fi From 742931e3e751d7b86d62117924e7888658990ccd Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:05:43 +0530 Subject: [PATCH 4/5] Add GrapheneOS binding regression tests and CI job The drift monitor proves the baseline still matches upstream GrapheneOS sources, but nothing proved Magisk's own artifacts still implement it. Bind the two together: - tests/test_grapheneos_bindings.py: generated JNI descriptors vs baseline, exec_spawn_replay.hpp constants vs Java indices/flag bits, replay FD sentinel, gen_jni_hooks.py regen-diff freshness, and the Android 17 memfd_file sepolicy rule - build.yml host-tests job: run both python suites on every PR so binding breakage fails CI instead of silently disabling Zygisk on GrapheneOS 17 --- .github/workflows/build.yml | 15 +++ tests/test_grapheneos_bindings.py | 158 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/test_grapheneos_bindings.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c015d6e5ea435..d7b7160eb3884 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -307,6 +307,21 @@ jobs: - name: Stop gradle daemon run: ./app/gradlew --stop + host-tests: + name: Host tests (python) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: GrapheneOS contract suites + run: python3 -m unittest tests.test_grapheneos_bindings tests.test_grapheneos_zygote_contract -v + + - name: Zygisk exec-spawn suite + working-directory: native/src/core/zygisk + run: python3 -m unittest discover -p 'test_*.py' -v + avd-test: name: Test API ${{ matrix.version }} (x86_64) runs-on: ubuntu-24.04 diff --git a/tests/test_grapheneos_bindings.py b/tests/test_grapheneos_bindings.py new file mode 100644 index 0000000000000..350a968d80f69 --- /dev/null +++ b/tests/test_grapheneos_bindings.py @@ -0,0 +1,158 @@ +"""Regression bindings between the GrapheneOS contract baseline and this repo. + +The drift monitor (test_grapheneos_zygote_contract.py) proves the *baseline* +still matches upstream GrapheneOS sources. This suite proves Magisk's *own* +artifacts still implement that baseline: + + 1. generated JNI hook descriptors == baseline jni_descriptors + 2. exec_spawn_replay.hpp constants == baseline Java indices / flag values + 3. jni_hooks.hpp is freshly generated (gen_jni_hooks.py regen-diff) + 4. the Android 17 memfd_file sepolicy rule is still present + +If any of these fail, Zygisk will silently stop matching GrapheneOS 17. +""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).parents[1] +BASELINE = REPO / "tests/fixtures/grapheneos_zygote_contract/baseline.json" +ZYGISK = REPO / "native/src/core/zygisk" +JNI_HOOKS = ZYGISK / "jni_hooks.hpp" +REPLAY_HEADER = ZYGISK / "exec_spawn_replay.hpp" +GENERATOR = ZYGISK / "gen_jni_hooks.py" +SEPOLICY_RULES = REPO / "native/src/sepolicy/rules.rs" + +# Generated variant -> baseline jni_descriptors key +DESCRIPTOR_BINDINGS = { + "nativeForkAndSpecialize_grapheneos_c": "nativeForkAndSpecialize", + "nativeSpecializeAppProcess_grapheneos_c": "nativeSpecializeAppProcess", + "nativeForkSystemServer_grapheneos_u": "nativeForkSystemServer", +} + + +def baseline() -> dict: + return json.loads(BASELINE.read_text(encoding="utf-8")) + + +def descriptor_of_variant(jni_text: str, variant: str) -> str: + """Extract the canonical JNI descriptor string of one generated variant.""" + lines = jni_text.splitlines() + try: + start = lines.index(f" // {variant}") + except ValueError: + raise AssertionError( + f"jni_hooks.hpp no longer contains variant '{variant}'; " + "the GrapheneOS binding is gone from gen_jni_hooks.py" + ) + for line in lines[start + 1 : start + 5]: + stripped = line.strip() + if stripped.startswith('"('): + return stripped.rstrip(",").strip('"') + raise AssertionError(f"variant '{variant}' has no descriptor string near line {start}") + + +class GrapheneOsBindingsTest(unittest.TestCase): + def setUp(self) -> None: + self.baseline = baseline() + self.jni_text = JNI_HOOKS.read_text(encoding="utf-8") + + def test_jni_descriptors_match_baseline(self) -> None: + for variant, method in DESCRIPTOR_BINDINGS.items(): + expected = self.baseline["jni_descriptors"][method] + actual = descriptor_of_variant(self.jni_text, variant) + self.assertEqual( + actual, + expected, + f"{variant} descriptor drifted from GrapheneOS baseline " + f"({method}). Regenerate hooks or update the baseline " + "deliberately.", + ) + + def test_native_fork_flags_index_matches_java_flags_index(self) -> None: + expected = self.baseline["extra_long_args"]["java_indices"]["IDX_FLAGS"] + match = re.search(r"kGrapheneOsNativeForkFlagsIndex = (\d+)", REPLAY_HEADER.read_text(encoding="utf-8")) + if match is None: + self.fail("kGrapheneOsNativeForkFlagsIndex missing from exec_spawn_replay.hpp") + self.assertEqual( + int(match.group(1)), + expected, + "C++ flags index no longer matches Java extra-long-args IDX_FLAGS; " + "the replay predicate would read the wrong flag", + ) + + def test_use_zygote_spawning_flag_matches_java_value(self) -> None: + expected = self.baseline["extra_long_args"]["use_zygote_spawning"]["value"] + header = REPLAY_HEADER.read_text(encoding="utf-8") + match = re.search(r"kGrapheneOsUseZygoteSpawning = 1LL << (\d+)", header) + if match is None: + self.fail("kGrapheneOsUseZygoteSpawning missing or changed shape") + self.assertEqual( + 1 << int(match.group(1)), + expected, + "USE_ZYGOTE_SPAWNING bit changed upstream; update " + "kGrapheneOsUseZygoteSpawning and the replay predicate together", + ) + + def test_replay_predicate_keeps_fd_sentinel(self) -> None: + sentinel = self.baseline["replay"]["fds_to_close_initial_sentinel"] + header = REPLAY_HEADER.read_text(encoding="utf-8") + self.assertIn("is_grapheneos_exec_spawn_replay_contract", header) + for i, fd in enumerate(sentinel): + self.assertIn( + f"fds_to_close[{i}] == {fd}", + header, + f"replay predicate lost the fds_to_close[{i}] == {fd} sentinel", + ) + self.assertIn( + "(native_fork_flags & kGrapheneOsUseZygoteSpawning) == 0", + header, + "replay predicate no longer requires USE_ZYGOTE_SPAWNING to be unset", + ) + + def test_generated_jni_hooks_are_fresh(self) -> None: + committed = JNI_HOOKS.read_bytes() + proc = None + with tempfile.TemporaryDirectory() as tmp: + shutil.copyfile(GENERATOR, Path(tmp) / GENERATOR.name) + proc = subprocess.run( + [sys.executable, GENERATOR.name], + cwd=tmp, + capture_output=True, + ) + generated = (Path(tmp) / "jni_hooks.hpp").read_bytes() if proc.returncode == 0 else b"" + if proc is None or proc.returncode != 0: + stderr = proc.stderr.decode(errors="replace") if proc else "generator did not run" + self.fail(f"gen_jni_hooks.py failed: {stderr}") + self.assertEqual( + committed, + generated, + "jni_hooks.hpp is stale: regenerate it with " + "`cd native/src/core/zygisk && python3 gen_jni_hooks.py`", + ) + + def test_memfd_file_sepolicy_rule_present(self) -> None: + rules = SEPOLICY_RULES.read_text(encoding="utf-8") + self.assertIn( + 'allow(["domain"], [proc], ["memfd_file"]', + rules, + "Android 17 memfd_file sepolicy rule vanished; Zygisk cannot map " + "executable memory on GrapheneOS without it", + ) + for perm in ("getattr", "read", "write", "map", "execute"): + self.assertRegex( + rules, + rf'"memfd_file"\], \[[^\]]*"{perm}"', + f"memfd_file rule lost permission '{perm}'", + ) + + +if __name__ == "__main__": + unittest.main() From 2b5ea0479e9d9a888cdbb63751c6a0e37b3d94b7 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:11:50 +0530 Subject: [PATCH 5/5] Harden GrapheneOS monitor and add bootloader-lock UI regression test - Detect newer upstream Android branches during live monitor runs and flag them in the report instead of silently watching a stale release line; re-baselining stays a manual decision - Run the contract/bindings and zygisk behavior suites on every periodic check, not only on pull requests - Check GrapheneOS for zygote changes twice a month instead of weekly - Add BootloaderLockUiTest covering the bootloader-lock UI guard from bf2e846: Uninstall Magisk and Direct Install visibility plus install sheet contents while locked vs unlocked, toggled via resetprop with an app restart per state change --- .../grapheneos_zygote_contract_monitor.yml | 12 +- .../magisk/test/BootloaderLockUiTest.kt | 132 ++++++++++++++++++ scripts/grapheneos_zygote_contract.py | 60 +++++++- scripts/test_common.sh | 4 + tests/test_grapheneos_zygote_contract.py | 55 ++++++++ 5 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 app/test/src/main/java/com/topjohnwu/magisk/test/BootloaderLockUiTest.kt diff --git a/.github/workflows/grapheneos_zygote_contract_monitor.yml b/.github/workflows/grapheneos_zygote_contract_monitor.yml index c78f27a1d23ac..dbfb4edf31bc3 100644 --- a/.github/workflows/grapheneos_zygote_contract_monitor.yml +++ b/.github/workflows/grapheneos_zygote_contract_monitor.yml @@ -3,7 +3,8 @@ name: GrapheneOS Zygote Contract Monitor on: workflow_dispatch: schedule: - - cron: "17 4 * * 1" + # Twice a month (1st and 15th) at 04:17 UTC. + - cron: "17 4 1,15 * *" permissions: contents: read @@ -49,6 +50,15 @@ jobs: grapheneos_zygote_contract_current.json grapheneos_zygote_contract_report.md + - name: Run contract regression suites + if: always() + run: python3 -m unittest tests.test_grapheneos_bindings tests.test_grapheneos_zygote_contract -v + + - name: Run zygisk behavior suite + if: always() + working-directory: native/src/core/zygisk + run: python3 -m unittest discover -p 'test_*.py' -v + - name: Sync drift issue if: github.repository == 'pixincreate/Magisk' env: diff --git a/app/test/src/main/java/com/topjohnwu/magisk/test/BootloaderLockUiTest.kt b/app/test/src/main/java/com/topjohnwu/magisk/test/BootloaderLockUiTest.kt new file mode 100644 index 0000000000000..cc7cdf009de74 --- /dev/null +++ b/app/test/src/main/java/com/topjohnwu/magisk/test/BootloaderLockUiTest.kt @@ -0,0 +1,132 @@ +package com.topjohnwu.magisk.test + +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import androidx.annotation.Keep +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Regression tests for the bootloader-lock UI guard introduced in bf2e846 + * (hide direct install / uninstall when the bootloader is locked). + * + * Runs against the real app UI with UiAutomator. The lock state comes from + * ro.boot.vbmeta.device_state, toggled with resetprop through Magisk's su. + * Because Info.isBootloaderLocked caches the value per process, every state + * change is followed by an app restart. + */ +@Keep +@RunWith(AndroidJUnit4::class) +class BootloaderLockUiTest { + + companion object { + private const val APP_PKG = "com.topjohnwu.magisk" + private const val LOCK_STATE_PROP = "ro.boot.vbmeta.device_state" + + private const val UNINSTALL = "Uninstall Magisk" + private const val DIRECT_INSTALL = "Direct install (Recommended)" + private const val PATCH_FILE = "Select and patch a file" + + // The home card action label depends on environment state + // (Reinstall/Install/Update), so accept all of them as "home ready". + private val ACTION_LABELS = arrayOf("Reinstall", "Install", "Update") + + private const val TIMEOUT_MS = 15_000L + private const val GRACE_MS = 3_000L + } + + private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + private val uiAutomation get() = InstrumentationRegistry.getInstrumentation().uiAutomation + + @After + fun tearDown() { + setLockStateAndRestart("unlocked") + } + + private fun shell(cmd: String): String { + val pfd = uiAutomation.executeShellCommand(cmd) + return AutoCloseInputStream(pfd).reader().use { it.readText() } + } + + private fun setLockStateAndRestart(state: String) { + shell("su -c 'resetprop $LOCK_STATE_PROP $state'") + shell("am force-stop $APP_PKG") + shell("monkey -p $APP_PKG -c android.intent.category.LAUNCHER 1") + assertNotNull( + "Magisk home never became ready after restart (state=$state)", + awaitAnyOf(*ACTION_LABELS) + ) + } + + private fun awaitAnyOf(vararg texts: String): String? { + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + texts.firstOrNull { device.findObject(By.text(it)) != null }?.let { return it } + Thread.sleep(250) + } + return null + } + + /** True if [text] shows up within [timeout]; used both for presence and short-grace absence checks. */ + private fun waitText(text: String, timeout: Long = GRACE_MS): Boolean { + val deadline = System.currentTimeMillis() + timeout + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.text(text)) != null) return true + Thread.sleep(250) + } + return false + } + + private fun openInstallSheet() { + val action = awaitAnyOf(*ACTION_LABELS) + assertNotNull("No install action button found on home screen", action) + val button = action?.let { device.findObject(By.text(it)) } + assertNotNull("Action button disappeared before it could be tapped", button) + button!!.click() + // Positive control: this row exists regardless of lock state, + // so reaching it proves the sheet actually opened. + assertTrue( + "Install sheet did not open ($PATCH_FILE never appeared)", + waitText(PATCH_FILE, TIMEOUT_MS) + ) + } + + @Test + fun testBootloaderLockTogglesInstallSurfaces() { + // --- Locked: destructive surfaces hidden --- + setLockStateAndRestart("locked") + + assertTrue( + "$UNINSTALL must be hidden when the bootloader is locked", + !waitText(UNINSTALL) + ) + + openInstallSheet() + assertTrue( + "$DIRECT_INSTALL must be hidden when the bootloader is locked", + !waitText(DIRECT_INSTALL) + ) + device.pressBack() + + // --- Unlocked: destructive surfaces restored --- + setLockStateAndRestart("unlocked") + + assertTrue( + "$UNINSTALL must be visible when the bootloader is unlocked", + waitText(UNINSTALL, TIMEOUT_MS) + ) + + openInstallSheet() + assertTrue( + "$DIRECT_INSTALL must be visible when the bootloader is unlocked", + waitText(DIRECT_INSTALL, TIMEOUT_MS) + ) + device.pressBack() + } +} diff --git a/scripts/grapheneos_zygote_contract.py b/scripts/grapheneos_zygote_contract.py index a38c57c8d8b66..47c747fef7fc3 100644 --- a/scripts/grapheneos_zygote_contract.py +++ b/scripts/grapheneos_zygote_contract.py @@ -13,9 +13,11 @@ REPOSITORY = "GrapheneOS/platform_frameworks_base" BRANCH = "17" API_URL = f"https://api.github.com/repos/{REPOSITORY}/commits/{BRANCH}" +BRANCHES_URL = f"https://api.github.com/repos/{REPOSITORY}/branches" RAW_BASE_URL = f"https://raw.githubusercontent.com/{REPOSITORY}" USER_AGENT = "Magisk GrapheneOS zygote contract monitor/1" MAX_SOURCE_BYTES = 2 * 1024 * 1024 +MAX_BRANCH_PAGES = 3 SOURCE_PATHS = ( "core/java/com/android/internal/os/ZygoteConnection.java", "core/java/com/android/internal/os/ZygoteExtraArgs.java", @@ -92,6 +94,35 @@ def fetch_sources(timeout: float) -> tuple[dict[str, str], str]: return sources, revision +def newer_upstream_branches(names: list[str]) -> list[str]: + pinned = int(BRANCH) + found = [name for name in names if re.fullmatch(r"\d+", name) and int(name) > pinned] + return sorted(found, key=int) + + +def list_remote_branch_names(timeout: float) -> list[str]: + names: list[str] = [] + url: str | None = f"{BRANCHES_URL}?per_page=100" + for _ in range(MAX_BRANCH_PAGES): + if url is None: + break + request = Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urlopen(request, timeout=timeout) as response: + link = response.headers.get("Link", "") + page_names = [entry["name"] for entry in json.load(response)] + except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError, KeyError, TypeError) as exc: + raise ContractError(f"failed to list GrapheneOS branches: {exc}") from exc + names.extend(name for name in page_names if isinstance(name, str)) + match = re.search(r'<([^>]+)>;\s*rel="next"', link) + url = match.group(1) if match else None + return names + + +def check_newer_branches(timeout: float) -> list[str]: + return newer_upstream_branches(list_remote_branch_names(timeout)) + + def read_sources(source_dir: Path) -> dict[str, str]: sources: dict[str, str] = {} for path in SOURCE_PATHS: @@ -226,7 +257,11 @@ def write_json(path: Path, contract: dict[str, object]) -> None: path.write_text(json.dumps(contract, sort_keys=True, indent=2) + "\n", encoding="utf-8") -def markdown_report(current: dict[str, object], baseline: dict[str, object] | None) -> str: +def markdown_report( + current: dict[str, object], + baseline: dict[str, object] | None, + newer_branches: list[str] | None = None, +) -> str: lines = ["", "# GrapheneOS Zygote contract drift", ""] lines.append(f"Current hash: `{current['canonical_sha256']}`") if "source_revision" in current: @@ -240,20 +275,39 @@ def markdown_report(current: dict[str, object], baseline: dict[str, object] | No else: lines.append("\nSemantic contract drift detected. Review `grapheneos_zygote_contract_current.json`.") lines.append("\nMonitored fields: JNI descriptors, extra-long-args indices/flags, replay fds sentinel, replay `processCommand(..., false, ...)`, and exec-spawn fork behavior.") + if newer_branches is not None: + if newer_branches: + lines.append( + f"\n**Newer upstream Android branch detected: {', '.join(newer_branches)}.** " + f"The committed baseline targets branch `{BRANCH}`. Re-baseline required: bump " + f"`BRANCH` in `scripts/grapheneos_zygote_contract.py`, refresh the fixtures and " + f"`baseline.json`, and review the new descriptors for additional JNI hook variants." + ) + else: + lines.append(f"\nNo upstream Android branch newer than `{BRANCH}` detected.") return "\n".join(lines) + "\n" def run(source_dir: Path | None, baseline_path: Path, out_json: Path, report: Path, timeout: float) -> int: + newer_branches: list[str] | None = None if source_dir: sources = read_sources(source_dir) revision = None else: sources, revision = fetch_sources(timeout) + try: + newer_branches = check_newer_branches(timeout) + except ContractError: + # The watchdog must never mask the primary drift signal. + newer_branches = None current = extract_contract(sources, revision) baseline = json.loads(baseline_path.read_text(encoding="utf-8")) if baseline_path.exists() else None write_json(out_json, current) - report.write_text(markdown_report(current, baseline), encoding="utf-8") - return 0 if baseline is not None and canonical_hash(current) == canonical_hash(baseline) else 2 + report.write_text(markdown_report(current, baseline, newer_branches), encoding="utf-8") + drift = baseline is None or canonical_hash(current) != canonical_hash(baseline) + if drift or newer_branches: + return 2 + return 0 def main_with_args_for_test(source_dir: Path, baseline: Path, out_json: Path, report: Path) -> int: diff --git a/scripts/test_common.sh b/scripts/test_common.sh index 6be47d189029c..a760559bc9225 100644 --- a/scripts/test_common.sh +++ b/scripts/test_common.sh @@ -76,6 +76,10 @@ run_tests() { # Run app tests am_instrument '.MagiskAppTest,.AdditionalTest' $app + # Test the bootloader-lock UI guard (needs su granted by MagiskAppTest, + # and must run before app hiding renames the package) + am_instrument '.BootloaderLockUiTest' $self + # Test app hiding am_instrument '.AppMigrationTest#testAppHide' $self diff --git a/tests/test_grapheneos_zygote_contract.py b/tests/test_grapheneos_zygote_contract.py index 19ae4b593644e..91b767f79154f 100644 --- a/tests/test_grapheneos_zygote_contract.py +++ b/tests/test_grapheneos_zygote_contract.py @@ -155,6 +155,61 @@ def test_workflow_handles_empty_issue_bodies_and_uploads_failure_report(self) -> 'elif [ -n "$issue_number" ] && [ "$issue_state" = "OPEN" ]; then', workflow, ) + self.assertIn('cron: "17 4 1,15 * *"', workflow) + self.assertIn("Run contract regression suites", workflow) + + def test_newer_branch_detection(self) -> None: + self.assertEqual(contract.newer_upstream_branches(["main", "17", "16", "18"]), ["18"]) + self.assertEqual(contract.newer_upstream_branches(["main", "17"]), []) + self.assertEqual(contract.newer_upstream_branches(["18", "19"]), ["18", "19"]) + self.assertEqual(contract.newer_upstream_branches([]), []) + + def test_report_flags_newer_upstream_branch(self) -> None: + current = contract.extract_contract(contract.read_sources(SOURCES)) + baseline = json.loads(BASELINE.read_text(encoding="utf-8")) + + clean = contract.markdown_report(current, baseline, newer_branches=[]) + alert = contract.markdown_report(current, baseline, newer_branches=["18"]) + + self.assertIn(f"No upstream Android branch newer than `{contract.BRANCH}` detected", clean) + self.assertIn("Newer upstream Android branch detected: 18", alert) + self.assertIn("Re-baseline required", alert) + + def test_live_run_alerts_when_newer_branch_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources = contract.read_sources(SOURCES) + out_json = tmp_path / "current.json" + report = tmp_path / "report.md" + with ( + mock.patch.object(contract, "fetch_sources", return_value=(sources, "a" * 40)), + mock.patch.object(contract, "check_newer_branches", return_value=["18"]), + ): + status = contract.run(None, BASELINE, out_json, report, 20.0) + + self.assertEqual(status, 2) + self.assertIn("Newer upstream Android branch detected: 18", report.read_text(encoding="utf-8")) + + def test_live_run_survives_watchdog_failure_without_masking_drift_signal(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources = contract.read_sources(SOURCES) + out_json = tmp_path / "current.json" + report = tmp_path / "report.md" + with ( + mock.patch.object(contract, "fetch_sources", return_value=(sources, "a" * 40)), + mock.patch.object( + contract, + "check_newer_branches", + side_effect=contract.ContractError("branches api down"), + ), + ): + status = contract.run(None, BASELINE, out_json, report, 20.0) + + self.assertEqual(status, 0) + body = report.read_text(encoding="utf-8") + self.assertIn("No semantic contract drift detected.", body) + self.assertNotIn("Newer upstream Android branch detected", body) if __name__ == "__main__":