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/.github/workflows/grapheneos_zygote_contract_monitor.yml b/.github/workflows/grapheneos_zygote_contract_monitor.yml
new file mode 100644
index 0000000000000..dbfb4edf31bc3
--- /dev/null
+++ b/.github/workflows/grapheneos_zygote_contract_monitor.yml
@@ -0,0 +1,90 @@
+name: GrapheneOS Zygote Contract Monitor
+
+on:
+ workflow_dispatch:
+ schedule:
+ # Twice a month (1st and 15th) at 04:17 UTC.
+ - cron: "17 4 1,15 * *"
+
+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: 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:
+ 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
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
new file mode 100644
index 0000000000000..47c747fef7fc3
--- /dev/null
+++ b/scripts/grapheneos_zygote_contract.py
@@ -0,0 +1,341 @@
+#!/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}"
+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",
+ "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 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:
+ 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,
+ 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:
+ 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.")
+ 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, 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:
+ 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/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/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},
+};
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()
diff --git a/tests/test_grapheneos_zygote_contract.py b/tests/test_grapheneos_zygote_contract.py
new file mode 100644
index 0000000000000..91b767f79154f
--- /dev/null
+++ b/tests/test_grapheneos_zygote_contract.py
@@ -0,0 +1,216 @@
+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,
+ )
+ 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__":
+ unittest.main()