From c86bdce439cba1b0317073a0a50dfda95dfde688 Mon Sep 17 00:00:00 2001 From: Jordan Ye Date: Sat, 1 Aug 2026 12:04:57 -0400 Subject: [PATCH 1/3] fix(system-mode): add durable lifecycle transaction --- .../magisk/core/tasks/MagiskInstaller.kt | 27 +- app/src/main/res/raw/manager.sh | 189 ++- buildSrc/src/main/java/Plugin.kt | 16 +- buildSrc/src/main/java/Setup.kt | 8 +- config.prop.sample | 7 + docs/system-mode/contract.md | 3 +- scripts/addon.d.sh | 15 +- scripts/avd_test.sh | 39 +- scripts/flash_script.sh | 50 +- scripts/system_mode_transaction.sh | 1377 +++++++++++++++++ scripts/system_mode_verify.sh | 91 ++ scripts/uninstaller.sh | 101 +- tests/security_lab/test_device_corpus.py | 12 + tests/system_mode/test_authorization.py | 65 + tests/system_mode/test_doctor.py | 2 + tests/system_mode/test_installer_safety.py | 263 +++- tests/system_mode/test_transaction.py | 323 ++++ tools/system_mode/authorization.py | 94 ++ tools/system_mode/doctor.py | 12 +- tools/system_mode/kitsune.py | 41 + .../system_mode/schemas/doctor-v1.schema.json | 4 +- .../schemas/install-manifest-v1.schema.json | 5 +- 22 files changed, 2580 insertions(+), 164 deletions(-) create mode 100644 scripts/system_mode_transaction.sh create mode 100644 scripts/system_mode_verify.sh create mode 100644 tests/system_mode/test_authorization.py create mode 100644 tests/system_mode/test_transaction.py create mode 100644 tools/system_mode/authorization.py diff --git a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt index 001e5c3f3..b235e3437 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt @@ -141,7 +141,14 @@ abstract class MagiskInstallImpl protected constructor( } // Extract scripts - for (script in listOf("util_functions.sh", "boot_patch.sh", "addon.d.sh", "stub.apk")) { + for (script in listOf( + "util_functions.sh", + "boot_patch.sh", + "addon.d.sh", + "system_mode_transaction.sh", + "system_mode_verify.sh", + "stub.apk", + )) { val dest = File(installDir, script) context.assets.open(script).writeTo(dest) } @@ -557,9 +564,12 @@ abstract class MagiskInstallImpl protected constructor( . "${'$'}1/system_mode_manager.sh" || exit 1 rm -f "${'$'}1/system_mode_manager.sh" || exit 1 . "${'$'}1/util_functions.sh" || exit 1 + [ "${'$'}KITSUNE_SOURCE_COMMIT" = "${'$'}3" ] || exit 1 + [ "${'$'}KITSUNE_UPSTREAM_BASE" = "${'$'}4" ] || exit 1 app_init xdirect_install_system "${'$'}1" "${'$'}2" - ' system-mode "$installDir" "$AppApkPath" + ' system-mode "$installDir" "$AppApkPath" \ + "${BuildConfig.SOURCE_COMMIT}" "${BuildConfig.UPSTREAM_BASE}" _system_mode_rc=${'$'}? rm -f "$manager" (exit "${'$'}_system_mode_rc") @@ -572,7 +582,8 @@ abstract class MagiskInstallImpl protected constructor( protected suspend fun fixEnv() = extractFiles() && "fix_env $installDir".sh().isSuccess - protected fun uninstall() = "run_uninstaller $AppApkPath".sh().isSuccess + protected fun uninstall() = + "run_uninstaller \"$AppApkPath\" \"${context.packageName}\"".sh().isSuccess protected fun cleanupInstallDir() { if (::installDir.isInitialized) { @@ -664,15 +675,7 @@ abstract class MagiskInstaller( ) : MagiskInstallImpl(console, logs) { override suspend fun operations() = uninstall() - override suspend fun exec(): Boolean { - val success = super.exec() - if (success) { - UiThreadHandler.handler.postDelayed(3000) { - Shell.cmd("pm uninstall ${context.packageName}").exec() - } - } - return success - } + override suspend fun exec() = super.exec() } class FixEnv(private val callback: () -> Unit) : MagiskInstallImpl() { diff --git a/app/src/main/res/raw/manager.sh b/app/src/main/res/raw/manager.sh index abddd9aa1..49c0f3f49 100644 --- a/app/src/main/res/raw/manager.sh +++ b/app/src/main/res/raw/manager.sh @@ -174,19 +174,35 @@ install_addond(){ fi return 1 fi + if [ "$SYSTEM_INSTALL" = "true" ]; then + [ -f "$installDir/addon.d.sh" ] && [ -f "$AppApkPath" ] || return 1 + [ "$(sm_sha256_file "$AppApkPath")" = "$SM_ARTIFACT_SHA256" ] || { + ui_print "! System Mode artifact changed after transaction preflight" + return 1 + } + fi if path_present "$addond/99-magisk.sh"; then mv "$addond/99-magisk.sh" "$script_backup" || failed=1 + if [ "$failed" = 0 ] && [ "$SYSTEM_INSTALL" = "true" ]; then + sm_fsync "$addond" || failed=1 + [ "$failed" != 0 ] || sm_failpoint addon-script-backed-up || failed=1 + fi fi if [ "$failed" = 0 ] && path_present "$addond/magisk"; then mv "$addond/magisk" "$dir_backup" || failed=1 + if [ "$failed" = 0 ] && [ "$SYSTEM_INSTALL" = "true" ]; then + sm_fsync "$addond" || failed=1 + [ "$failed" != 0 ] || sm_failpoint addon-directory-backed-up || failed=1 + fi fi [ "$failed" != 0 ] || publish_started=true if [ "$SYSTEM_INSTALL" = "true" ]; then - [ "$failed" = 0 ] && cp -prLf "$installDir"/. /system/etc/init/magisk || failed=1 - [ "$failed" = 0 ] && cp "$installDir/addon.d.sh" "$addond/99-magisk.sh" || failed=1 - [ "$failed" = 0 ] && cp "$AppApkPath" /system/etc/init/magisk/magisk.apk || failed=1 - [ "$failed" = 0 ] && chmod 755 /system/etc/init/magisk/* || failed=1 - [ "$failed" = 0 ] && sed -i "s/^SYSTEMINSTALL=.*/SYSTEMINSTALL=true/g" "$addond/99-magisk.sh" || failed=1 + local addon_stage="$addond/.99-magisk.sh.kitsune-new" + [ "$failed" = 0 ] && cp "$installDir/addon.d.sh" "$addon_stage" || failed=1 + [ "$failed" = 0 ] && sed -i "s/^SYSTEMINSTALL=.*/SYSTEMINSTALL=true/g" "$addon_stage" || failed=1 + [ "$failed" = 0 ] && chmod 755 "$addon_stage" || failed=1 + [ "$failed" = 0 ] && chcon u:object_r:system_file:s0 "$addon_stage" 2>/dev/null || [ ! -d /sys/fs/selinux ] || failed=1 + [ "$failed" = 0 ] && sm_atomic_publish "$addon_stage" "$addond/99-magisk.sh" addon-script-published || failed=1 else [ "$failed" = 0 ] && mkdir -p "$addond/magisk" || failed=1 [ "$failed" = 0 ] && cp -prLf "$installDir"/. "$addond/magisk" || failed=1 @@ -201,7 +217,8 @@ install_addond(){ # backup that did succeed. Once publication starts, both old paths were # either absent or safely renamed, so remove only the new partial data. if [ "$publish_started" = true ]; then - rm -rf "$addond/99-magisk.sh" "$addond/magisk" || failed=2 + rm -rf "$addond/99-magisk.sh" "$addond/magisk" \ + "$addond/.99-magisk.sh.kitsune-new" || failed=2 fi if path_present "$script_backup"; then mv "$script_backup" "$addond/99-magisk.sh" || failed=2 @@ -250,7 +267,7 @@ run_uninstaller() { rm -rf /dev/tmp mkdir -p /dev/tmp/install unzip -o "$1" "assets/*" "lib/*" -d /dev/tmp/install - INSTALLER=/dev/tmp/install sh /dev/tmp/install/assets/uninstaller.sh dummy 1 "$1" + INSTALLER=/dev/tmp/install sh /dev/tmp/install/assets/uninstaller.sh dummy 1 "$1" "$2" } restore_imgs() { @@ -458,6 +475,7 @@ on property:vold.decrypt=trigger_restart_framework on property:sys.boot_completed=1 mkdir /data/adb/magisk 755 exec u:r:su:s0 root root -- $MAGISKTMP/magisk --auto-selinux --boot-complete + exec u:r:su:s0 root root -- $MAGISKSYSTEMDIR/system_mode_verify.sh on property:init.svc.zygote=restarting exec u:r:su:s0 root root -- $MAGISKTMP/magisk --auto-selinux --zygote-restart @@ -550,6 +568,10 @@ begin_system_installation(){ return 1 fi SYSTEM_INSTALL_HAD_DIR=true + if command -v sm_fsync >/dev/null 2>&1; then + sm_fsync "$(sm_parent "$target")" || return 1 + sm_failpoint system-payload-backed-up || return 1 + fi fi if [ -e "$legacy" ] || [ -L "$legacy" ]; then if ! mv "$legacy" "$legacy.kitsune-old"; then @@ -557,6 +579,10 @@ begin_system_installation(){ return 1 fi SYSTEM_INSTALL_HAD_RC=true + if command -v sm_fsync >/dev/null 2>&1; then + sm_fsync "$(sm_parent "$legacy")" || return 1 + sm_failpoint legacy-init-backed-up || return 1 + fi fi SYSTEM_INSTALL_BEGIN_COMPLETE=true return 0 @@ -706,7 +732,7 @@ direct_install_system(){ VENDORDIR="$MIRRORDIR/vendor" ODM_DIR="$MIRRORDIR/odm" - local MAGISKTMP_TO_INSTALL=/sbin + local MAGISKTMP_TO_INSTALL if $BOOTMODE; then umount -l "/proc/$$/attr" @@ -757,6 +783,13 @@ direct_install_system(){ fi + [ -f "$INSTALLDIR/system_mode_transaction.sh" ] && + [ -f "$INSTALLDIR/system_mode_verify.sh" ] || { + ui_print "! System Mode transaction support is missing" + return 1 + } + . "$INSTALLDIR/system_mode_transaction.sh" || return 1 + sm_configure "$INSTALLDIR" "$MIRRORDIR" "$MAGISKSYSTEMDIR" "$INSTALLDIR/busybox" || return 1 ui_print "- Cleaning up enviroment..." { @@ -783,32 +816,53 @@ direct_install_system(){ fi ui_print "- Copy files to system partition" - for magisk in $magisk_applet magiskpolicy magiskinit stub.apk; do + for magisk in $magisk_applet magiskpolicy magiskinit stub.apk system_mode_transaction.sh system_mode_verify.sh; do [ -f "$INSTALLDIR/$magisk" ] || { ui_print "! Missing install payload: $magisk"; return 1; } done + local install_artifact="${3:-$INSTALLDIR/magisk.apk}" + sm_begin_transaction "$install_artifact" || return 1 + MAGISKTMP_TO_INSTALL="$SM_RUNTIME_PATH" + local payload_stage + payload_stage="$(sm_real_path "$SM_STAGING_PATH")" || return 1 + rm -rf "$payload_stage" || return 1 + mkdir -p "$payload_stage" || return 1 + cp -prLf "$INSTALLDIR"/. "$payload_stage" || { ui_print "! Unable to stage System Mode payload"; return 1; } + cp -f "$install_artifact" "$payload_stage/magisk.apk" || return 1 + printf 'SYSTEMMODE=true\nRECOVERYMODE=false\nSYSTEM_MODE_SCHEMA=1\nINSTALL_ID=%s\n' \ + "$SM_INSTALL_ID" >"$payload_stage/config" || return 1 + for magisk in $magisk_applet magiskpolicy magiskinit stub.apk system_mode_transaction.sh system_mode_verify.sh magisk.apk config; do + [ -f "$payload_stage/$magisk" ] || { ui_print "! Staged payload is incomplete: $magisk"; return 1; } + done + if ! chcon -R u:object_r:system_file:s0 "$payload_stage"; then + if [ -d /sys/fs/selinux ]; then + ui_print "! Unable to label System Mode payload" + return 1 + fi + ui_print "W: SELinux is inactive; payload labeling was skipped" + fi + chmod -R 700 "$payload_stage" || return 1 + sm_fsync_tree "$payload_stage" || return 1 + sm_fsync "$(sm_parent "$payload_stage")" || return 1 + sm_failpoint payload-staged || return 1 + begin_system_installation "$MIRRORDIR" || return 1 + sm_atomic_publish "$payload_stage" "$MIRRORDIR$MAGISKSYSTEMDIR" payload-published || return 1 local runtime_dir="$ROOTDIR$MAGISKTMP_TO_INSTALL" - if [ ! -d "$runtime_dir" ]; then + if [ -d "$runtime_dir" ]; then + sm_probe_writable_directory "$runtime_dir" || { + ui_print "! Runtime path is not durably writable: $MAGISKTMP_TO_INSTALL" + return 1 + } + else if [ -e "$runtime_dir" ] || [ -L "$runtime_dir" ]; then ui_print "! Runtime path exists but is not a directory: $MAGISKTMP_TO_INSTALL" return 1 fi mkdir "$runtime_dir" || { ui_print "! Can't create runtime path $MAGISKTMP_TO_INSTALL"; return 1; } SYSTEM_INSTALL_CREATED_RUNTIME_DIR="$runtime_dir" + sm_fsync "$runtime_dir" "$(sm_parent "$runtime_dir")" || return 1 + sm_failpoint runtime-directory-created || return 1 fi - mkdir -p "$MIRRORDIR$MAGISKSYSTEMDIR" || return 1 - for magisk in $magisk_applet magiskpolicy magiskinit stub.apk; do - cat "$INSTALLDIR/$magisk" >"$MIRRORDIR$MAGISKSYSTEMDIR/$magisk" || { ui_print "! Unable to write Magisk binaries to system"; return 1; } - done - echo -e "SYSTEMMODE=true\nRECOVERYMODE=false" >"$MIRRORDIR$MAGISKSYSTEMDIR/config" || return 1 - if ! chcon -R u:object_r:system_file:s0 "$MIRRORDIR$MAGISKSYSTEMDIR"; then - if [ -d /sys/fs/selinux ]; then - ui_print "! Unable to label System Mode payload" - return 1 - fi - ui_print "W: SELinux is inactive; payload labeling was skipped" - fi - chmod -R 700 "$MIRRORDIR$MAGISKSYSTEMDIR" || return 1 if [ "$API" -gt 24 ]; then @@ -834,13 +888,7 @@ direct_install_system(){ if ! is_rootfs; then { ui_print "- Patch sepolicy file" - local sepol file - for file in /vendor/etc/selinux/precompiled_sepolicy /odm/etc/selinux/precompiled_sepolicy /system/etc/selinux/precompiled_sepolicy /system_root/sepolicy /system_root/sepolicy_debug /system_root/sepolicy.unlocked; do - if [ -f "$MIRRORDIR$file" ]; then - sepol="$file" - break - fi - done + local sepol="$SM_POLICY_PATH" if [ -z "$sepol" ]; then ui_print "! Cannot find sepolicy file" return 1 @@ -854,12 +902,16 @@ direct_install_system(){ { ui_print "! Backup failed"; return 1; } # copy file to cache cp -af "$MIRRORDIR$sepol" "$INSTALLDIR/sepol.in" || return 1 - if ! "$INSTALLDIR/magiskinit" --patch-sepol "$INSTALLDIR/sepol.in" "$INSTALLDIR/sepol.out" || ! cp -af "$INSTALLDIR/sepol.out" "$MIRRORDIR$sepol"; then + if ! "$INSTALLDIR/magiskinit" --patch-sepol "$INSTALLDIR/sepol.in" "$INSTALLDIR/sepol.out"; then ui_print "! Unable to patch sepolicy file" rm -f "$INSTALLDIR/sepol.in" "$INSTALLDIR/sepol.out" return 1 fi - rm -f "$INSTALLDIR/sepol.in" "$INSTALLDIR/sepol.out" || return 1 + "$SM_BB" chmod "$($SM_BB stat -c %a "$MIRRORDIR$sepol")" "$INSTALLDIR/sepol.out" || return 1 + "$SM_BB" chown "$($SM_BB stat -c %u "$MIRRORDIR$sepol"):$($SM_BB stat -c %g "$MIRRORDIR$sepol")" "$INSTALLDIR/sepol.out" || return 1 + chcon --reference="$MIRRORDIR$sepol" "$INSTALLDIR/sepol.out" 2>/dev/null || true + sm_atomic_publish "$INSTALLDIR/sepol.out" "$MIRRORDIR$sepol" policy-published || return 1 + rm -f "$INSTALLDIR/sepol.in" || return 1 ui_print "- Patching sepolicy file success!" fi } @@ -868,24 +920,22 @@ direct_install_system(){ ui_print "- Add init boot script" local hijackrc { - hijackrc="$MIRRORDIR/system/etc/init/magisk.rc" - if [ -f "$MIRRORDIR/system/etc/init/bootanim.rc" ]; then - stage_file_rollback "$MIRRORDIR/system/etc/init/bootanim.rc" || \ - { ui_print "! Transaction backup failed"; return 1; } - SYSTEM_INSTALL_BOOTANIM=true - SYSTEM_INSTALL_BOOTANIM_HAD_GZ="$STAGED_FILE_HAD_GZ" - backup_restore "$MIRRORDIR/system/etc/init/bootanim.rc" || return 1 - hijackrc="$MIRRORDIR/system/etc/init/bootanim.rc" - fi + sm_restore_legacy_bootanim || return 1 + hijackrc="$(sm_real_path "$SM_INIT_PATH")" || return 1 } - echo "$(magiskrc "$MAGISKTMP_TO_INSTALL")" >>"$hijackrc" || return 1 + local staged="$hijackrc.kitsune-new" + magiskrc "$MAGISKTMP_TO_INSTALL" >"$staged" || return 1 + chmod 644 "$staged" || return 1 + chcon u:object_r:system_file:s0 "$staged" 2>/dev/null || [ ! -d /sys/fs/selinux ] || return 1 + sm_atomic_publish "$staged" "$hijackrc" init-published || return 1 fi ui_print "[*] Reflash your ROM if your ROM is unable to start" ui_print " and do not use this method to install Magisk" if [ "$defer_cleanup" != true ]; then - commit_system_installation || return 1 + commit_system_installation || { sm_abort_transaction; return 1; } + sm_commit_transaction || { sm_abort_transaction; return 1; } $BOOTMODE && installer_cleanup fi return 0 @@ -894,22 +944,71 @@ direct_install_system(){ xdirect_install_system() { - direct_install_system "$1" true || { cleanup_system_installation || ui_print "! System Mode rollback incomplete"; installer_cleanup; return 1; } - fix_env "$1" true || { cleanup_system_installation || ui_print "! System Mode rollback incomplete"; installer_cleanup; return 1; } + # fix_env removes the extracted installer after publishing /data/adb/magisk. + # Keep the transaction applet on tmpfs so commit and rollback never depend on + # either the deleted source or a runtime directory that rollback may remove. + local transaction_dir="/dev/.kitsune-system-mode.$$" + local transaction_bb="$transaction_dir/busybox" + local direct_result + if ! mkdir "$transaction_dir" || + ! chmod 0700 "$transaction_dir" || + ! cp -f "$1/busybox" "$transaction_bb" || + ! chmod 0700 "$transaction_bb"; then + rm -rf "$transaction_dir" + ui_print "! Unable to pin the System Mode transaction applet" + installer_cleanup + return 1 + fi + + direct_install_system "$1" true "$2" + direct_result=$? + SM_BB="$transaction_bb" + if [ "$direct_result" != 0 ]; then + cleanup_system_installation || ui_print "! System Mode rollback incomplete" + if command -v sm_abort_transaction >/dev/null 2>&1; then + sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete" + fi + installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" + return 1 + fi + fix_env "$1" true || { cleanup_system_installation || ui_print "! System Mode rollback incomplete"; sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete"; installer_cleanup; "$transaction_bb" rm -rf "$transaction_dir"; return 1; } + local runtime_magisk="$MAGISKBIN/magisk32" + [ "$IS64BIT" = true ] && runtime_magisk="$MAGISKBIN/magisk64" + ui_print "- Normalize Magisk runtime metadata" + "$runtime_magisk" --restorecon || { + rollback_env || ui_print "! Runtime rollback incomplete" + cleanup_system_installation || ui_print "! System Mode rollback incomplete" + sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete" + installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" + return 1 + } install_addond "$2" "true" "true" || { rollback_env || ui_print "! Runtime rollback incomplete" cleanup_system_installation || ui_print "! System Mode rollback incomplete" + sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete" installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" return 1 } commit_system_installation || { rollback_env || ui_print "! Runtime rollback incomplete" cleanup_system_installation || ui_print "! System Mode rollback incomplete" + sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete" installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" return 1 } commit_env || ui_print "W: Runtime transaction cleanup was incomplete" + sm_commit_transaction || { + sm_abort_transaction || ui_print "! Durable System Mode rollback incomplete" + installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" + return 1 + } installer_cleanup + "$transaction_bb" rm -rf "$transaction_dir" || ui_print "W: Transaction applet cleanup was incomplete" return 0 } diff --git a/buildSrc/src/main/java/Plugin.kt b/buildSrc/src/main/java/Plugin.kt index 23b4eb2d5..b82aa79e2 100644 --- a/buildSrc/src/main/java/Plugin.kt +++ b/buildSrc/src/main/java/Plugin.kt @@ -10,6 +10,7 @@ import java.util.* private val props = Properties() private var commitHash = "" +private var sourceRevision = "" object Config { operator fun get(key: String): String? { @@ -22,6 +23,8 @@ object Config { val version: String get() = get("version") ?: commitHash val versionCode: Int get() = get("magisk.versionCode")!!.toInt() val stubVersion: String get() = get("magisk.stubVersion")!! + val sourceCommit: String get() = sourceRevision + val upstreamBase: String get() = get("upstreamBase") ?: "154121f3dd92e67a3d8e3f518684932c0f9783e6" } class MagiskPlugin : Plugin { @@ -36,7 +39,7 @@ class MagiskPlugin : Plugin { if (config.exists()) config.inputStream().use { props.load(it) } - commitHash = Config["version"] ?: run { + sourceRevision = Config["sourceCommit"] ?: run { val builder = FileRepositoryBuilder() .readEnvironment() .findGitDir(rootProject.rootDir) @@ -48,15 +51,20 @@ class MagiskPlugin : Plugin { builder.build().use { repo -> val refId = repo.resolve(Constants.HEAD) ?: throw GradleException("Cannot resolve the Git HEAD revision") - repo.newObjectReader().use { reader -> - "${reader.abbreviate(refId, 8).name()}-kitsune" - } + refId.name() } } + if (!sourceRevision.matches(Regex("^[a-f0-9]{40}$"))) { + throw GradleException("sourceCommit must be the full lowercase 40-character Git revision") + } + commitHash = Config["version"] ?: "${sourceRevision.take(8)}-kitsune" if (!commitHash.contains("kitsune")) { throw GradleException( "Version must contain the lowercase Kitsune identity marker 'kitsune'" ) } + if (!Config.upstreamBase.matches(Regex("^[a-f0-9]{40}$"))) { + throw GradleException("upstreamBase must be a full lowercase 40-character Git revision") + } } } diff --git a/buildSrc/src/main/java/Setup.kt b/buildSrc/src/main/java/Setup.kt index a0824c127..3ec2d2b83 100644 --- a/buildSrc/src/main/java/Setup.kt +++ b/buildSrc/src/main/java/Setup.kt @@ -184,6 +184,8 @@ private fun Project.setupAppCommon() { defaultConfig { buildConfigField("int", "STUB_VERSION", Config.stubVersion) + buildConfigField("String", "SOURCE_COMMIT", "\"${Config.sourceCommit}\"") + buildConfigField("String", "UPSTREAM_BASE", "\"${Config.upstreamBase}\"") } buildTypes { @@ -307,6 +309,7 @@ fun Project.setupApp() { from(rootProject.file("scripts")) { include("util_functions.sh", "boot_patch.sh", "addon.d.sh") include("uninstaller.sh", "module_installer.sh") + include("system_mode_transaction.sh", "system_mode_verify.sh") } from(rootProject.file("tools/bootctl")) into("chromeos") { @@ -322,7 +325,10 @@ fun Project.setupApp() { filter { it.replace( "#MAGISK_VERSION_STUB", - "MAGISK_VER='${Config.version}'\nMAGISK_VER_CODE=${Config.versionCode}" + "MAGISK_VER='${Config.version}'\n" + + "MAGISK_VER_CODE=${Config.versionCode}\n" + + "KITSUNE_SOURCE_COMMIT='${Config.sourceCommit}'\n" + + "KITSUNE_UPSTREAM_BASE='${Config.upstreamBase}'" ) } filter("eol" to FixCrLfFilter.CrLf.newInstance("lf")) diff --git a/config.prop.sample b/config.prop.sample index 09cf7145a..5339315ea 100644 --- a/config.prop.sample +++ b/config.prop.sample @@ -8,6 +8,13 @@ # used by compatible external integrations. Default: -kitsune version=string +# Full source identity embedded in privileged install manifests. Defaults to +# the repository HEAD; source archives without .git must set this explicitly. +sourceCommit=0123456789abcdef0123456789abcdef01234567 + +# Audited upstream lineage recorded separately from the Kitsune product version. +upstreamBase=154121f3dd92e67a3d8e3f518684932c0f9783e6 + # Output path. Default: out outdir=string diff --git a/docs/system-mode/contract.md b/docs/system-mode/contract.md index 751cf3c23..6fdc229d1 100644 --- a/docs/system-mode/contract.md +++ b/docs/system-mode/contract.md @@ -55,7 +55,8 @@ The following flags record evidence created by an external lab workflow; they do invent the evidence: - `--init-import-proven`: a harmless marker RC was parsed on a disposable snapshot; -- `--snapshot-id`, `--backup-digest`, `--restore-command`, and `--recovery-verified`: one complete, +- `--snapshot-id`, `--backup-location`, `--backup-digest`, `--restore-command`, and + `--recovery-verified`: one complete, exercised recovery tuple; - `--backing-write-probe passed --cold-boots N`: a removed controlled marker survived at least three cold boots; diff --git a/scripts/addon.d.sh b/scripts/addon.d.sh index 3a5786464..332d88d2d 100644 --- a/scripts/addon.d.sh +++ b/scripts/addon.d.sh @@ -131,7 +131,9 @@ main() { abort "! System Mode is disabled in release builds" fi - remove_system_su + if [ "$SYSTEMINSTALL" != "true" ]; then + remove_system_su + fi if [ "$SYSTEMINSTALL" = "true" ]; then local system_apk=$MAGISKBIN/magisk.apk rm -f ./manager.sh @@ -145,10 +147,17 @@ main() { BOOTMODE="$BOOTMODE_OLD" . $MAGISKBIN/util_functions.sh if $BOOTMODE; then - direct_install_system "$MAGISKBIN" || { cleanup_system_installation; unmount_system_mirrors; abort "! Installation failed"; } + direct_install_system "$MAGISKBIN" true "$system_apk" || { cleanup_system_installation; sm_abort_transaction; unmount_system_mirrors; abort "! Installation failed"; } else - direct_install_system "$MAGISKBIN" || { cleanup_system_installation; abort "! Installation failed"; } + direct_install_system "$MAGISKBIN" true "$system_apk" || { cleanup_system_installation; sm_abort_transaction; abort "! Installation failed"; } fi + install_addond "$system_apk" true true || { + cleanup_system_installation + sm_abort_transaction + abort "! addon.d publication failed" + } + commit_system_installation || { sm_abort_transaction; abort "! Installation commit failed"; } + sm_commit_transaction || { sm_abort_transaction; abort "! Durable installation commit failed"; } else install_magisk fi diff --git a/scripts/avd_test.sh b/scripts/avd_test.sh index b42514e05..43636ec49 100755 --- a/scripts/avd_test.sh +++ b/scripts/avd_test.sh @@ -10,6 +10,7 @@ boot_timeout="${KITSUNE_AVD_BOOT_TIMEOUT:-600}" show_kernel="${KITSUNE_AVD_SHOW_KERNEL:-1}" corpus_iterations="${KITSUNE_SECURITY_CORPUS_ITERATIONS:-2}" emu_pid= +emu_boot_id= emu_args=() avd_created=false @@ -175,6 +176,27 @@ stop_emu() { fi } +wait_emu_transport_gone() { + local deadline=$((SECONDS + 30)) + local state + while [ "$SECONDS" -lt "$deadline" ]; do + state=$("${adb_cmd[@]}" get-state 2>/dev/null) || state= + [ -z "$state" ] && return 0 + sleep 1 + done + echo "Emulator transport emulator-$emulator_port is still occupied" >&2 + return 1 +} + +start_emu() { + # ADB can retain the previous emulator transport briefly after QEMU exits. + # Starting a replacement during that window can make boot_completed from the + # old guest authorize patching the new SDK image against the wrong process. + wait_emu_transport_gone || return 1 + "$emu" "@$avd_name" "${emu_args[@]}" & + emu_pid=$! +} + cleanup_avd_backups() { if [ -n "$ramdisk" ]; then rm -f -- "${ramdisk}.bak" @@ -241,13 +263,20 @@ wait_emu() { local property=$1 local expected=$2 local deadline=$((SECONDS + boot_timeout)) - local result + local result boot_id active_avd # This polling loop works with macOS's Bash 3.2 and checks only the explicit # AVD serial, so another connected target can never receive these commands. while kill -0 "$emu_pid" 2>/dev/null; do result=$("${adb_cmd[@]}" exec-out getprop "$property" 2>/dev/null | tr -d '\r') || true - if [ "$result" = "$expected" ]; then + boot_id=$("${adb_cmd[@]}" exec-out cat /proc/sys/kernel/random/boot_id 2>/dev/null | tr -d '\r') || true + active_avd=$("${adb_cmd[@]}" exec-out getprop ro.boot.qemu.avd_name 2>/dev/null | tr -d '\r') || true + if [ -z "$active_avd" ]; then + active_avd=$("${adb_cmd[@]}" exec-out getprop ro.kernel.qemu.avd_name 2>/dev/null | tr -d '\r') || true + fi + if [ "$result" = "$expected" ] && [ "$active_avd" = "$avd_name" ] && + [ -n "$boot_id" ] && [ "$boot_id" != "$emu_boot_id" ]; then + emu_boot_id=$boot_id return 0 fi if [ "$SECONDS" -ge "$deadline" ]; then @@ -321,8 +350,7 @@ test_emu() { print_title "* Testing $pkg ($variant)" - "$emu" "@$avd_name" "${emu_args[@]}" & - emu_pid=$! + start_emu || return 1 if ! wait_emu sys.boot_completed 1 || ! wait_test_ready "$variant"; then print_error "Failed to boot $variant image for $pkg" return 1 @@ -435,8 +463,7 @@ run_test() { # Launch stock emulator print_title "* Launching $pkg" restore_avd - "$emu" "@$avd_name" "${emu_args[@]}" & - emu_pid=$! + start_emu || return 1 # API 36 images launched with -no-boot-anim may never publish the # init.svc.bootanim property even though Android has completed booting. if ! wait_emu sys.boot_completed 1; then diff --git a/scripts/flash_script.sh b/scripts/flash_script.sh index 97bed184f..729154282 100644 --- a/scripts/flash_script.sh +++ b/scripts/flash_script.sh @@ -30,10 +30,6 @@ getvar SYSTEMMODE SYSTEMINSTALL="$SYSTEMMODE" [ -z "$SYSTEMINSTALL" ] && SYSTEMINSTALL=false -if echo "$3" | grep -q "systemmagisk"; then - SYSTEMINSTALL=true -fi - setup_flashable ############ @@ -80,8 +76,11 @@ if [ "$SYSTEMINSTALL" = "true" ]; then abort "! System Mode is disabled in release builds" fi -# Check if system root is installed and remove -$BOOTMODE || remove_system_su +# Legacy system-root cleanup is part of ordinary boot-image installation, not +# the manifest-owned System Mode transaction. +if [ "$SYSTEMINSTALL" != "true" ]; then + $BOOTMODE || remove_system_su +fi ############## # Environment @@ -89,19 +88,26 @@ $BOOTMODE || remove_system_su ui_print "- Constructing environment" -# Copy required files -rm -rf $MAGISKBIN/* 2>/dev/null -mkdir -p $MAGISKBIN 2>/dev/null -cp -af $BINDIR/. $COMMONDIR/. $BBBIN $MAGISKBIN +# Build System Mode only in the installer staging directory. xdirect_install_system +# snapshots the old runtime before fix_env publishes this complete tree. +INSTALL_ENV=$MAGISKBIN +if [ "$SYSTEMINSTALL" = "true" ]; then + INSTALL_ENV=$MAGISKBINTMP +fi +rm -rf "$INSTALL_ENV"/* 2>/dev/null +mkdir -p "$INSTALL_ENV" 2>/dev/null +cp -af $BINDIR/. $COMMONDIR/. $BBBIN "$INSTALL_ENV" # Remove files only used by the Magisk app -rm -f $MAGISKBIN/bootctl $MAGISKBIN/main.jar \ - $MAGISKBIN/module_installer.sh $MAGISKBIN/uninstaller.sh +rm -f "$INSTALL_ENV/bootctl" "$INSTALL_ENV/main.jar" \ + "$INSTALL_ENV/module_installer.sh" "$INSTALL_ENV/uninstaller.sh" -cat "$APK" >"$MAGISKBIN/magisk.apk" -cp -af $MAGISKBIN/* $MAGISKBINTMP +cat "$APK" >"$INSTALL_ENV/magisk.apk" +if [ "$SYSTEMINSTALL" != "true" ]; then + cp -af "$MAGISKBIN"/* "$MAGISKBINTMP" +fi -chmod -R 755 $MAGISKBIN +chmod -R 755 "$INSTALL_ENV" chmod -R 755 $MAGISKBINTMP @@ -122,19 +128,13 @@ if [ "$SYSTEMINSTALL" == "true" ]; then rm -f ./manager.sh BOOTMODE="$BOOTMODE_OLD" . $COMMONDIR/util_functions.sh - ADDOND_MAGISK=/system/etc/init/magisk - [ -f "$ADDOND/99-magisk.sh" ] && sed -i "s/^SYSTEMINSTALL=.*/SYSTEMINSTALL=true/g" $ADDOND/99-magisk.sh - if $BOOTMODE; then - direct_install_system "$MAGISKBINTMP" || { cleanup_system_installation; unmount_system_mirrors; abort "! Installation failed"; } - else - direct_install_system "$MAGISKBINTMP" || { cleanup_system_installation; abort "! Installation failed"; } - fi + xdirect_install_system "$MAGISKBINTMP" "$APK" || abort "! Installation failed" else install_magisk fi # addon.d -if [ -d /system/addon.d ]; then +if [ "$SYSTEMINSTALL" != "true" ] && [ -d /system/addon.d ]; then ui_print "- Adding addon.d survival script" blockdev --setrw /dev/block/mapper/system$SLOT 2>/dev/null mount -o rw,remount /system || mount -o rw,remount / @@ -151,7 +151,9 @@ if [ -d /system/addon.d ]; then fi # Cleanups -$BOOTMODE || recovery_cleanup +if [ "$SYSTEMINSTALL" != "true" ]; then + $BOOTMODE || recovery_cleanup +fi rm -rf $TMPDIR ui_print "- Done" diff --git a/scripts/system_mode_transaction.sh b/scripts/system_mode_transaction.sh new file mode 100644 index 000000000..f861e8677 --- /dev/null +++ b/scripts/system_mode_transaction.sh @@ -0,0 +1,1377 @@ +#!/system/bin/sh + +# Persistent transaction support shared by app, recovery, addon.d, boot +# verification, and uninstall entry points. This file is sourced; it never +# mutates a target merely by being loaded. +# shellcheck disable=SC2016 + +SM_SCHEMA_VERSION=1 +SM_AUTHORIZATION_FILE=/data/local/tmp/kitsune-system-mode-recovery-v1.env +SM_STATE_DIR=/data/adb/kitsune/system-mode +SM_TRANSACTION_FILE=$SM_STATE_DIR/transaction.env +SM_MANIFEST_COPY=$SM_STATE_DIR/install-manifest.json +SM_OWNERSHIP_FILE=$SM_STATE_DIR/ownership.tsv +SM_ORIGINAL_FILE=$SM_STATE_DIR/originals.tsv +SM_JOURNAL_FILE=$SM_STATE_DIR/journal.tsv +SM_BOOT_PROOF=$SM_STATE_DIR/boot-verified.env +SM_TAB="$(printf '\t')" + +sm_log() { + if command -v ui_print >/dev/null 2>&1; then + ui_print "$1" + else + echo "$1" + fi +} + +sm_configure() { + SM_INSTALL_DIR="$1" + SM_MIRROR="${2:-/}" + SM_SYSTEM_DIR="${3:-/system/etc/init/magisk}" + SM_BB="${4:-$SM_INSTALL_DIR/busybox}" + [ -x "$SM_BB" ] || SM_BB=/data/adb/magisk/busybox + [ -x "$SM_BB" ] || { + sm_log "! System Mode transaction runtime is unavailable" + return 1 + } + case "$SM_MIRROR" in + /|/proc/*/attr) ;; + *) sm_log "! Invalid System Mode transaction mirror"; return 1 ;; + esac + return 0 +} + +sm_get() { + local key="$1" file="$2" + [ -f "$file" ] || return 1 + "$SM_BB" sed -n "s/^${key}=//p" "$file" | "$SM_BB" head -n 1 +} + +sm_sha256_file() { + "$SM_BB" sha256sum "$1" 2>/dev/null | "$SM_BB" awk 'NR == 1 { print $1 }' +} + +sm_path_present() { + [ -e "$1" ] || [ -L "$1" ] +} + +sm_real_path() { + case "$1" in + /data|/data/*) printf '%s\n' "$1" ;; + /*) + if [ "$SM_MIRROR" = / ]; then + printf '%s\n' "$1" + else + printf '%s%s\n' "$SM_MIRROR" "$1" + fi + ;; + *) return 1 ;; + esac +} + +sm_parent() { + "$SM_BB" dirname "$1" +} + +sm_fsync() { + [ "$#" -gt 0 ] || return 1 + "$SM_BB" fsync "$@" +} + +sm_fsync_existing_parent() { + local parent + parent="$(sm_parent "$1")" || return 1 + # An absent leaf cannot have changed a namespace whose parent is also + # absent. Do not turn an exact rollback/uninstall into a false failure just + # because an optional tree such as /system/addon.d never existed. + [ ! -d "$parent" ] || sm_fsync "$parent" +} + +sm_probe_writable_directory() { + local directory="$1" probe="$1/.kitsune-system-mode-write-probe.$$" + [ -d "$directory" ] || return 1 + sm_path_present "$probe" && return 1 + if ! (set -C; : >"$probe") 2>/dev/null; then + return 1 + fi + if ! sm_fsync "$probe" "$directory"; then + "$SM_BB" rm -f "$probe" + sm_fsync "$directory" 2>/dev/null || true + return 1 + fi + "$SM_BB" rm -f "$probe" || return 1 + sm_fsync "$directory" +} + +sm_mountpoint_for() { + local path="$1" + "$SM_BB" awk -v target="$path" ' + $2 == "/" || target == $2 || index(target, $2 "/") == 1 { + if (length($2) > length(best)) best=$2 + } + END { if (best != "") print best; else exit 1 } + ' /proc/mounts +} + +sm_remount() { + local mode="$1" mountpoint="$2" + if [ -x /system/bin/mount ]; then + /system/bin/mount -o "$mode,remount" "$mountpoint" + else + "$SM_BB" mount -o "$mode,remount" "$mountpoint" + fi +} + +sm_prepare_persistent_mounts() { + local canonical real mountpoint options seen= + SM_PERSISTENT_REMOUNTED= + for canonical in "$SM_SYSTEM_DIR" "$SM_SYSTEM_DIR.rc" "$SM_INIT_PATH" \ + "$SM_POLICY_PATH" /system/etc/init/bootanim.rc \ + /system/addon.d/99-magisk.sh /system/addon.d/magisk /data/adb/magisk; do + [ -n "$canonical" ] || continue + real="$(sm_real_path "$canonical")" || return 1 + mountpoint="$(sm_mountpoint_for "$real")" || return 1 + case "|$seen|" in *"|$mountpoint|"*) continue ;; esac + seen="${seen:+$seen|}$mountpoint" + options="$("$SM_BB" awk -v mountpoint="$mountpoint" '$2 == mountpoint { print $4; exit }' /proc/mounts)" || return 1 + case ",$options," in *,rw,*) continue ;; esac + sm_log "- Remounting System Mode filesystem read-write: $mountpoint" + sm_remount rw "$mountpoint" || { + sm_log "! Unable to remount System Mode filesystem: $mountpoint" + return 1 + } + SM_PERSISTENT_REMOUNTED="${SM_PERSISTENT_REMOUNTED:+$SM_PERSISTENT_REMOUNTED }$mountpoint" + done + return 0 +} + +sm_restore_persistent_mounts() { + local mountpoint failed=0 + # The managed Android mountpoints are fixed paths without whitespace. + # shellcheck disable=SC2086 + for mountpoint in $SM_PERSISTENT_REMOUNTED; do + sm_remount ro "$mountpoint" || failed=1 + done + SM_PERSISTENT_REMOUNTED= + [ "$failed" = 0 ] +} + +sm_fsync_tree() { + local root="$1" path + if [ -f "$root" ]; then + sm_fsync "$root" || return 1 + elif [ -d "$root" ]; then + "$SM_BB" find "$root" -type f -print | while IFS= read -r path; do + sm_fsync "$path" || exit 1 + done || return 1 + "$SM_BB" find "$root" -type d -print | "$SM_BB" sort -r | while IFS= read -r path; do + sm_fsync "$path" || exit 1 + done || return 1 + fi + return 0 +} + +sm_context() { + # Disabled/permissive kernels do not enforce labels, and some writable + # emulator filesystems present the same xattr as "unlabeled" after a cold + # start. Keep strict context ownership where SELinux actually enforces it. + if [ ! -r /sys/fs/selinux/enforce ] || [ "$(cat /sys/fs/selinux/enforce 2>/dev/null)" != 1 ]; then + printf '%s\n' - + return 0 + fi + "$SM_BB" ls -Zd "$1" 2>/dev/null | "$SM_BB" awk ' + NR == 1 { + for (i = 1; i <= NF; i++) { + if ($i ~ /^u:[^:]+:[^:]+:s[0-9]/) { + print $i + exit + } + } + } + ' +} + +sm_atomic_publish() { + local staged="$1" destination="$2" boundary="${3:-atomic-publish}" parent expected actual size keep short + parent="$(sm_parent "$destination")" || return 1 + case "${KITSUNE_SYSTEM_MODE_FAIL_AT:-}" in + "enospc:$boundary"|"erofs:$boundary") + sm_log "! Injected System Mode I/O failure at $boundary" + return 98 + ;; + "short-write:$boundary") + [ -f "$staged" ] || return 98 + expected="$(sm_sha256_file "$staged")" || return 98 + size="$($SM_BB stat -c %s "$staged")" || return 98 + keep=$((size > 0 ? size - 1 : 0)) + short="$staged.kitsune-short" + "$SM_BB" dd if="$staged" of="$short" bs=1 count="$keep" 2>/dev/null || return 98 + "$SM_BB" mv -f "$short" "$staged" || return 98 + actual="$(sm_sha256_file "$staged")" || return 98 + [ "$actual" != "$expected" ] || return 98 + sm_log "! Detected injected System Mode short write at $boundary" + return 98 + ;; + esac + sm_fsync_tree "$staged" || return 1 + case "${KITSUNE_SYSTEM_MODE_FAIL_AT:-}" in + "fsync-file:$boundary") sm_log "! Injected System Mode file fsync failure at $boundary"; return 98 ;; + "rename:$boundary") sm_log "! Injected System Mode rename failure at $boundary"; return 98 ;; + esac + "$SM_BB" mv -f "$staged" "$destination" || return 1 + case "${KITSUNE_SYSTEM_MODE_FAIL_AT:-}" in + "fsync-parent:$boundary") sm_log "! Injected System Mode parent fsync failure at $boundary"; return 98 ;; + esac + sm_fsync "$destination" "$parent" || return 1 + sm_failpoint "$boundary" +} + +sm_failpoint() { + local boundary="$1" + case "${KITSUNE_SYSTEM_MODE_FAIL_AT:-}" in + "$boundary") + sm_log "! Injected System Mode failure at $boundary" + return 97 + ;; + "process-death:$boundary") + sm_log "! Injected System Mode process death at $boundary" + kill -9 $$ + ;; + "reboot:$boundary") + sm_log "! Injected System Mode reboot at $boundary" + /system/bin/reboot 2>/dev/null + kill -9 $$ + ;; + esac + return 0 +} + +sm_digest_path() { + local path="$1" item rel kind digest mode uid gid target + if [ -f "$path" ]; then + sm_sha256_file "$path" + elif [ -L "$path" ]; then + target="$($SM_BB readlink "$path")" || return 1 + printf 'link:%s' "$target" | "$SM_BB" sha256sum | "$SM_BB" awk '{ print $1 }' + elif [ -d "$path" ]; then + ( + cd "$path" || exit 1 + "$SM_BB" find . -mindepth 1 -print | "$SM_BB" sort | while IFS= read -r item; do + rel="${item#./}" + mode="$($SM_BB stat -c %a "$item")" || exit 1 + uid="$($SM_BB stat -c %u "$item")" || exit 1 + gid="$($SM_BB stat -c %g "$item")" || exit 1 + if [ -f "$item" ]; then + kind="file" + digest="$(sm_sha256_file "$item")" || exit 1 + elif [ -L "$item" ]; then + kind="link" + digest="$(printf 'link:%s' "$($SM_BB readlink "$item")" | "$SM_BB" sha256sum | "$SM_BB" awk '{ print $1 }')" || exit 1 + elif [ -d "$item" ]; then + kind=directory + digest=- + else + kind=other + digest=- + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$rel" "$kind" "$digest" "$mode" "$uid" "$gid" + done + ) | "$SM_BB" sha256sum | "$SM_BB" awk '{ print $1 }' + else + printf '%s\n' - + fi +} + +sm_json_escape() { + printf '%s' "$1" | "$SM_BB" sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' +} + +sm_decode() { + printf '%s' "$1" | "$SM_BB" base64 -d 2>/dev/null +} + +sm_valid_single_line() { + [ -n "$1" ] || return 1 + [ "$(printf '%s' "$1" | "$SM_BB" tr -d '\r\n')" = "$1" ] || return 1 + ! printf '%s' "$1" | LC_ALL=C "$SM_BB" grep -q '[[:cntrl:]]' +} + +sm_valid_hex() { + local value="$1" length="$2" + [ "${#value}" -eq "$length" ] || return 1 + case "$value" in *[!a-f0-9]*|'') return 1 ;; esac + return 0 +} + +sm_valid_uuid() { + printf '%s\n' "$1" | LC_ALL=C "$SM_BB" grep -Eq \ + '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' +} + +sm_live_abis() { + local abis + abis="$(getprop ro.product.cpu.abilist)" + [ -n "$abis" ] || abis="$(getprop ro.product.cpu.abi)" + printf '%s\n' "$abis" +} + +sm_validate_live_target() { + local live_fingerprint live_api live_abis + live_fingerprint="$(printf '%s' "$(getprop ro.build.fingerprint)" | "$SM_BB" sha256sum | "$SM_BB" awk '{ print $1 }')" + live_api="$(getprop ro.build.version.sdk)" + live_abis="$(sm_live_abis)" + [ "$live_fingerprint" = "$SM_FINGERPRINT_SHA256" ] || { + sm_log "! System Mode transaction belongs to a different target" + return 1 + } + [ "$live_api" = "$SM_TARGET_API" ] || { + sm_log "! System Mode transaction API does not match the live target" + return 1 + } + [ "$live_abis" = "$SM_TARGET_ABIS" ] || { + sm_log "! System Mode transaction ABI list does not match the live target" + return 1 + } + return 0 +} + +sm_validate_authorization() { + local auth="$SM_AUTHORIZATION_FILE" schema fingerprint api + local adapter_b64 init_b64 selinux_b64 snapshot_b64 location_b64 restore_b64 + [ -f "$auth" ] || { + sm_log "! Run 'kitsune system-mode authorize' with a verified doctor report first" + return 1 + } + schema="$(sm_get SCHEMA_VERSION "$auth")" + [ "$schema" = "$SM_SCHEMA_VERSION" ] || { sm_log "! Unsupported recovery authorization"; return 1; } + SM_REPORT_SHA256="$(sm_get REPORT_SHA256 "$auth")" + fingerprint="$(sm_get FINGERPRINT_SHA256 "$auth")" + SM_BACKUP_SHA256="$(sm_get BACKUP_SHA256 "$auth")" + case "$SM_REPORT_SHA256:$fingerprint:$SM_BACKUP_SHA256" in + *[!a-f0-9:]*|*::*|:*|*:) sm_log "! Invalid recovery authorization digest"; return 1 ;; + esac + [ "${#SM_REPORT_SHA256}" -eq 64 ] && [ "${#fingerprint}" -eq 64 ] && + [ "${#SM_BACKUP_SHA256}" -eq 64 ] || { + sm_log "! Invalid recovery authorization digest length" + return 1 + } + api="$(sm_get TARGET_API "$auth")" + case "$api" in *[!0-9]*|'') sm_log "! Invalid recovery authorization API"; return 1 ;; esac + adapter_b64="$(sm_get ADAPTER_ID_B64 "$auth")" + init_b64="$(sm_get INIT_DIRECTORY_B64 "$auth")" + selinux_b64="$(sm_get SELINUX_STRATEGY_B64 "$auth")" + snapshot_b64="$(sm_get SNAPSHOT_ID_B64 "$auth")" + location_b64="$(sm_get BACKUP_LOCATION_B64 "$auth")" + restore_b64="$(sm_get RESTORE_COMMAND_B64 "$auth")" + SM_TARGET_ABIS="$(sm_decode "$(sm_get TARGET_ABIS_B64 "$auth")")" || return 1 + SM_ADAPTER_ID="$(sm_decode "$adapter_b64")" || return 1 + SM_AUTH_INIT_DIRECTORY="$(sm_decode "$init_b64")" || return 1 + SM_SELINUX_STRATEGY="$(sm_decode "$selinux_b64")" || return 1 + SM_SNAPSHOT_ID="$(sm_decode "$snapshot_b64")" || return 1 + SM_BACKUP_LOCATION="$(sm_decode "$location_b64")" || return 1 + SM_RESTORE_COMMAND="$(sm_decode "$restore_b64")" || return 1 + if ! sm_valid_single_line "$SM_TARGET_ABIS" || ! sm_valid_single_line "$SM_ADAPTER_ID" || + ! sm_valid_single_line "$SM_AUTH_INIT_DIRECTORY" || ! sm_valid_single_line "$SM_SELINUX_STRATEGY" || + ! sm_valid_single_line "$SM_SNAPSHOT_ID" || ! sm_valid_single_line "$SM_BACKUP_LOCATION" || + ! sm_valid_single_line "$SM_RESTORE_COMMAND"; then + sm_log "! Recovery authorization contains invalid text" + return 1 + fi + case "$SM_ADAPTER_ID" in *[!A-Za-z0-9._-]*) sm_log "! Invalid adapter identifier"; return 1 ;; esac + case "$SM_AUTH_INIT_DIRECTORY" in + /system/etc/init|/system/etc/init/hw|/vendor/etc/init|/odm/etc/init|/product/etc/init|/system_ext/etc/init) ;; + *) sm_log "! Unauthorized init directory"; return 1 ;; + esac + SM_FINGERPRINT_SHA256="$fingerprint" + SM_TARGET_API="$api" + sm_validate_live_target || return 1 + return 0 +} + +sm_select_strategies() { + local candidate real + SM_RUNTIME_PATH="${KITSUNE_SYSTEM_RUNTIME:-}" + if [ -n "$SM_RUNTIME_PATH" ]; then + case "$SM_RUNTIME_PATH" in /sbin|/debug_ramdisk) ;; *) sm_log "! Invalid adapter runtime path"; return 1 ;; esac + elif [ -d /debug_ramdisk ] && [ -w /debug_ramdisk ]; then + SM_RUNTIME_PATH=/debug_ramdisk + else + SM_RUNTIME_PATH=/sbin + fi + SM_INIT_PATH="$SM_AUTH_INIT_DIRECTORY/magisk.rc" + real="$(sm_real_path "$SM_AUTH_INIT_DIRECTORY")" || return 1 + sm_probe_writable_directory "$real" || { sm_log "! Authorized init directory is unavailable"; return 1; } + + SM_POLICY_PATH= + for candidate in \ + /vendor/etc/selinux/precompiled_sepolicy \ + /odm/etc/selinux/precompiled_sepolicy \ + /system/etc/selinux/precompiled_sepolicy \ + /system_root/sepolicy \ + /system_root/sepolicy_debug \ + /system_root/sepolicy.unlocked; do + real="$(sm_real_path "$candidate")" || return 1 + if [ -f "$real" ]; then + SM_POLICY_PATH="$candidate" + break + fi + done + if [ -d /sys/fs/selinux ] && [ -z "$SM_POLICY_PATH" ]; then + sm_log "! Cannot identify the SELinux policy used by the next boot" + return 1 + fi + case "$SM_SELINUX_STRATEGY" in + precompiled) case "$SM_POLICY_PATH" in */precompiled_sepolicy) ;; *) sm_log "! Doctor and installer policy strategies disagree"; return 1 ;; esac ;; + monolithic|split) ;; + disabled) SM_POLICY_PATH= ;; + *) sm_log "! Unsupported SELinux strategy"; return 1 ;; + esac + SM_POLICY_SOURCE="$SM_POLICY_PATH" + if command -v is_rootfs >/dev/null 2>&1 && is_rootfs; then + SM_POLICY_PATH= + SM_SELINUX_STRATEGY="live+$SM_SELINUX_STRATEGY" + fi + return 0 +} + +sm_write_transaction() { + local staged="$SM_STATE_DIR/.transaction.env.new" + "$SM_BB" mkdir -p "$SM_STATE_DIR" || return 1 + "$SM_BB" chmod 0700 /data/adb/kitsune "$SM_STATE_DIR" 2>/dev/null || return 1 + { + printf 'SCHEMA_VERSION=%s\n' "$SM_SCHEMA_VERSION" + printf 'INSTALL_ID=%s\n' "$SM_INSTALL_ID" + printf 'TRANSACTION_ID=%s\n' "$SM_TRANSACTION_ID" + printf 'STATE=%s\n' "$SM_STATE" + printf 'PRIOR_STATE=%s\n' "$SM_PRIOR_STATE" + printf 'FINGERPRINT_SHA256=%s\n' "$SM_FINGERPRINT_SHA256" + printf 'TARGET_API=%s\n' "$SM_TARGET_API" + printf 'REPORT_SHA256=%s\n' "$SM_REPORT_SHA256" + printf 'BACKUP_SHA256=%s\n' "$SM_BACKUP_SHA256" + printf 'ADAPTER_ID_B64=%s\n' "$(printf '%s' "$SM_ADAPTER_ID" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'TARGET_ABIS_B64=%s\n' "$(printf '%s' "$SM_TARGET_ABIS" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'SNAPSHOT_ID_B64=%s\n' "$(printf '%s' "$SM_SNAPSHOT_ID" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'BACKUP_LOCATION_B64=%s\n' "$(printf '%s' "$SM_BACKUP_LOCATION" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'RESTORE_COMMAND_B64=%s\n' "$(printf '%s' "$SM_RESTORE_COMMAND" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'INIT_PATH=%s\n' "$SM_INIT_PATH" + printf 'POLICY_PATH=%s\n' "$SM_POLICY_PATH" + printf 'POLICY_SOURCE=%s\n' "$SM_POLICY_SOURCE" + printf 'RUNTIME_PATH=%s\n' "$SM_RUNTIME_PATH" + printf 'SELINUX_STRATEGY=%s\n' "$SM_SELINUX_STRATEGY" + printf 'SOURCE_COMMIT=%s\n' "$SM_SOURCE_COMMIT" + printf 'UPSTREAM_BASE=%s\n' "$SM_UPSTREAM_BASE" + printf 'ARTIFACT_SHA256=%s\n' "$SM_ARTIFACT_SHA256" + printf 'PRODUCT_VERSION_B64=%s\n' "$(printf '%s' "$SM_PRODUCT_VERSION" | "$SM_BB" base64 | "$SM_BB" tr -d '\n')" + printf 'COMMIT_BOOT_ID=%s\n' "$SM_COMMIT_BOOT_ID" + printf 'ROLLBACK_DIR=%s\n' "$SM_ROLLBACK_DIR" + printf 'STAGING_PATH=%s\n' "$SM_STAGING_PATH" + } >"$staged" || return 1 + "$SM_BB" chmod 0600 "$staged" || return 1 + sm_atomic_publish "$staged" "$SM_TRANSACTION_FILE" "state:$SM_STATE" +} + +sm_load_transaction() { + [ -f "$SM_TRANSACTION_FILE" ] || return 1 + [ "$(sm_get SCHEMA_VERSION "$SM_TRANSACTION_FILE")" = "$SM_SCHEMA_VERSION" ] || return 1 + SM_INSTALL_ID="$(sm_get INSTALL_ID "$SM_TRANSACTION_FILE")" + SM_TRANSACTION_ID="$(sm_get TRANSACTION_ID "$SM_TRANSACTION_FILE")" + SM_STATE="$(sm_get STATE "$SM_TRANSACTION_FILE")" + SM_PRIOR_STATE="$(sm_get PRIOR_STATE "$SM_TRANSACTION_FILE")" + SM_FINGERPRINT_SHA256="$(sm_get FINGERPRINT_SHA256 "$SM_TRANSACTION_FILE")" + SM_TARGET_API="$(sm_get TARGET_API "$SM_TRANSACTION_FILE")" + SM_REPORT_SHA256="$(sm_get REPORT_SHA256 "$SM_TRANSACTION_FILE")" + SM_BACKUP_SHA256="$(sm_get BACKUP_SHA256 "$SM_TRANSACTION_FILE")" + SM_ADAPTER_ID="$(sm_decode "$(sm_get ADAPTER_ID_B64 "$SM_TRANSACTION_FILE")")" + SM_TARGET_ABIS="$(sm_decode "$(sm_get TARGET_ABIS_B64 "$SM_TRANSACTION_FILE")")" + SM_SNAPSHOT_ID="$(sm_decode "$(sm_get SNAPSHOT_ID_B64 "$SM_TRANSACTION_FILE")")" + SM_BACKUP_LOCATION="$(sm_decode "$(sm_get BACKUP_LOCATION_B64 "$SM_TRANSACTION_FILE")")" + SM_RESTORE_COMMAND="$(sm_decode "$(sm_get RESTORE_COMMAND_B64 "$SM_TRANSACTION_FILE")")" + SM_INIT_PATH="$(sm_get INIT_PATH "$SM_TRANSACTION_FILE")" + SM_POLICY_PATH="$(sm_get POLICY_PATH "$SM_TRANSACTION_FILE")" + SM_POLICY_SOURCE="$(sm_get POLICY_SOURCE "$SM_TRANSACTION_FILE")" + SM_RUNTIME_PATH="$(sm_get RUNTIME_PATH "$SM_TRANSACTION_FILE")" + SM_SELINUX_STRATEGY="$(sm_get SELINUX_STRATEGY "$SM_TRANSACTION_FILE")" + SM_SOURCE_COMMIT="$(sm_get SOURCE_COMMIT "$SM_TRANSACTION_FILE")" + SM_UPSTREAM_BASE="$(sm_get UPSTREAM_BASE "$SM_TRANSACTION_FILE")" + SM_ARTIFACT_SHA256="$(sm_get ARTIFACT_SHA256 "$SM_TRANSACTION_FILE")" + SM_PRODUCT_VERSION="$(sm_decode "$(sm_get PRODUCT_VERSION_B64 "$SM_TRANSACTION_FILE")")" + SM_COMMIT_BOOT_ID="$(sm_get COMMIT_BOOT_ID "$SM_TRANSACTION_FILE")" + SM_ROLLBACK_DIR="$(sm_get ROLLBACK_DIR "$SM_TRANSACTION_FILE")" + SM_STAGING_PATH="$(sm_get STAGING_PATH "$SM_TRANSACTION_FILE")" + sm_valid_uuid "$SM_INSTALL_ID" && sm_valid_uuid "$SM_TRANSACTION_ID" || return 1 + case "$SM_STATE" in UNINSTALLED|PREFLIGHTED|STAGED|COMMITTED|BOOT_VERIFIED|ROLLBACK_REQUIRED|ROLLING_BACK|FAILED) ;; *) return 1 ;; esac + case "$SM_PRIOR_STATE" in UNINSTALLED|BOOT_VERIFIED) ;; *) return 1 ;; esac + case "$SM_INIT_PATH" in /system/etc/init/magisk.rc|/system/etc/init/hw/magisk.rc|/vendor/etc/init/magisk.rc|/odm/etc/init/magisk.rc|/product/etc/init/magisk.rc|/system_ext/etc/init/magisk.rc) ;; *) return 1 ;; esac + case "$SM_RUNTIME_PATH" in /sbin|/debug_ramdisk) ;; *) return 1 ;; esac + case "$SM_POLICY_PATH" in ""|/vendor/etc/selinux/precompiled_sepolicy|/odm/etc/selinux/precompiled_sepolicy|/system/etc/selinux/precompiled_sepolicy|/system_root/sepolicy|/system_root/sepolicy_debug|/system_root/sepolicy.unlocked) ;; *) return 1 ;; esac + case "$SM_POLICY_SOURCE" in ""|/vendor/etc/selinux/precompiled_sepolicy|/odm/etc/selinux/precompiled_sepolicy|/system/etc/selinux/precompiled_sepolicy|/system_root/sepolicy|/system_root/sepolicy_debug|/system_root/sepolicy.unlocked) ;; *) return 1 ;; esac + [ "$SM_ROLLBACK_DIR" = "$SM_STATE_DIR/rollback/$SM_TRANSACTION_ID" ] || return 1 + [ "$SM_STAGING_PATH" = "$(sm_parent "$SM_SYSTEM_DIR")/.magisk.kitsune-stage-$SM_TRANSACTION_ID" ] || return 1 + sm_valid_hex "$SM_FINGERPRINT_SHA256" 64 && sm_valid_hex "$SM_REPORT_SHA256" 64 && + sm_valid_hex "$SM_BACKUP_SHA256" 64 && sm_valid_hex "$SM_SOURCE_COMMIT" 40 && + sm_valid_hex "$SM_UPSTREAM_BASE" 40 && sm_valid_hex "$SM_ARTIFACT_SHA256" 64 || return 1 + case "$SM_TARGET_API" in *[!0-9]*|'') return 1 ;; esac + case "$SM_ADAPTER_ID" in *[!A-Za-z0-9._-]*|'') return 1 ;; esac + case "$SM_TARGET_ABIS" in *[!A-Za-z0-9,._-]*|''|,*|*,|*,,*) return 1 ;; esac + case "$SM_SELINUX_STRATEGY" in precompiled|monolithic|split|disabled|live+precompiled|live+monolithic|live+split|live+disabled) ;; *) return 1 ;; esac + sm_valid_single_line "$SM_SNAPSHOT_ID" && sm_valid_single_line "$SM_BACKUP_LOCATION" && + sm_valid_single_line "$SM_RESTORE_COMMAND" && sm_valid_single_line "$SM_PRODUCT_VERSION" || return 1 + [ -z "$SM_COMMIT_BOOT_ID" ] || sm_valid_uuid "$SM_COMMIT_BOOT_ID" || return 1 + sm_validate_live_target +} + +sm_update_state() { + SM_STATE="$1" + sm_write_transaction +} + +sm_label_path() { + case "$1" in + payload) printf '%s\n' "$SM_SYSTEM_DIR" ;; + legacy_rc) printf '%s.rc\n' "$SM_SYSTEM_DIR" ;; + init_rc) printf '%s\n' "$SM_INIT_PATH" ;; + policy) printf '%s\n' "$SM_POLICY_PATH" ;; + policy_gz) [ -n "$SM_POLICY_PATH" ] && printf '%s.gz\n' "$SM_POLICY_PATH" ;; + bootanim) printf '%s\n' /system/etc/init/bootanim.rc ;; + bootanim_gz) printf '%s\n' /system/etc/init/bootanim.rc.gz ;; + runtime) printf '%s\n' /data/adb/magisk ;; + addon_script) printf '%s\n' /system/addon.d/99-magisk.sh ;; + addon_dir) printf '%s\n' /system/addon.d/magisk ;; + *) return 1 ;; + esac +} + +sm_state_path() { + case "$1" in + transaction) printf '%s\n' "$SM_TRANSACTION_FILE" ;; + manifest_copy) printf '%s\n' "$SM_MANIFEST_COPY" ;; + ownership) printf '%s\n' "$SM_OWNERSHIP_FILE" ;; + originals) printf '%s\n' "$SM_ORIGINAL_FILE" ;; + journal) printf '%s\n' "$SM_JOURNAL_FILE" ;; + boot_proof) printf '%s\n' "$SM_BOOT_PROOF" ;; + original_dir) printf '%s\n' "$SM_STATE_DIR/original" ;; + *) return 1 ;; + esac +} + +sm_snapshot_state_metadata() { + local label source destination + "$SM_BB" mkdir -p "$SM_ROLLBACK_DIR/state" || return 1 + for label in transaction manifest_copy ownership originals journal boot_proof original_dir; do + source="$(sm_state_path "$label")" || return 1 + destination="$SM_ROLLBACK_DIR/state/$label" + "$SM_BB" mkdir -p "$destination" || return 1 + if sm_path_present "$source"; then + "$SM_BB" cp -a "$source" "$destination/data" || return 1 + sm_fsync_tree "$destination/data" || return 1 + printf 'present\n' >"$destination/present" || return 1 + else + printf 'absent\n' >"$destination/absent" || return 1 + fi + sm_fsync_tree "$destination" || return 1 + sm_fsync "$SM_ROLLBACK_DIR/state" || return 1 + sm_failpoint "snapshot-state:$label" || return 1 + done + sm_fsync "$SM_ROLLBACK_DIR" "$SM_STATE_DIR/rollback" "$SM_STATE_DIR" || return 1 +} + +sm_restore_state_metadata() { + local label destination source failed=0 + # Restore the transaction record last. Until then, any interrupted recovery + # remains visibly tied to this rollback directory and can be retried. + for label in original_dir boot_proof journal originals ownership manifest_copy transaction; do + destination="$(sm_state_path "$label")" || return 1 + source="$SM_ROLLBACK_DIR/state/$label" + [ -d "$source" ] || { failed=1; break; } + "$SM_BB" rm -rf "$destination" || { failed=1; break; } + if [ -f "$source/present" ]; then + "$SM_BB" mkdir -p "$(sm_parent "$destination")" || { failed=1; break; } + "$SM_BB" cp -a "$source/data" "$destination" || { failed=1; break; } + sm_fsync_tree "$destination" || { failed=1; break; } + elif [ ! -f "$source/absent" ]; then + failed=1 + break + fi + sm_fsync "$(sm_parent "$destination")" || { failed=1; break; } + sm_failpoint "rollback-state:$label" || { failed=1; break; } + done + [ "$failed" = 0 ] +} + +sm_cleanup_staging() { + local path real failed=0 + for path in \ + "$SM_STAGING_PATH" \ + "$SM_INIT_PATH.kitsune-new" \ + /system/etc/init/bootanim.rc.kitsune-stock-new \ + /system/addon.d/.99-magisk.sh.kitsune-new \ + "$SM_SYSTEM_DIR/.install-manifest.json.new" \ + "$SM_SYSTEM_DIR/.install-manifest.state-new"; do + [ -n "$path" ] || continue + real="$(sm_real_path "$path")" || { failed=1; continue; } + "$SM_BB" rm -rf "$real" "$real.kitsune-short" || failed=1 + sm_fsync_existing_parent "$real" 2>/dev/null || failed=1 + done + for path in \ + "$SM_STATE_DIR/.transaction.env.new" \ + "$SM_STATE_DIR/.install-manifest.json.new" \ + "$SM_STATE_DIR/.install-manifest.copy.new" \ + "$SM_STATE_DIR/.install-manifest.state-new" \ + "$SM_STATE_DIR/.boot-verified.env.new" \ + "$SM_ORIGINAL_FILE.new" "$SM_OWNERSHIP_FILE.new" "$SM_JOURNAL_FILE.new"; do + "$SM_BB" rm -f "$path" "$path.kitsune-short" || failed=1 + done + sm_fsync "$SM_STATE_DIR" 2>/dev/null || failed=1 + [ "$failed" = 0 ] +} + +sm_restore_preflight() { + local rollback="$SM_ROLLBACK_DIR" + sm_cleanup_staging || { + sm_update_state FAILED + return 1 + } + if ! sm_restore_state_metadata; then + sm_update_state FAILED + sm_log "! Transaction metadata recovery failed; use the verified external restore" + return 1 + fi + "$SM_BB" rm -rf "$rollback" || return 1 + sm_fsync "$SM_STATE_DIR/rollback" "$SM_STATE_DIR" || return 1 + return 0 +} + +sm_snapshot_one() { + local label="$1" canonical real destination + canonical="$(sm_label_path "$label")" || return 1 + [ -n "$canonical" ] || return 0 + real="$(sm_real_path "$canonical")" || return 1 + destination="$SM_ROLLBACK_DIR/$label" + "$SM_BB" mkdir -p "$destination" || return 1 + if sm_path_present "$real"; then + "$SM_BB" cp -a "$real" "$destination/data" || return 1 + sm_fsync_tree "$destination/data" || return 1 + printf 'present\n' >"$destination/present" || return 1 + else + printf 'absent\n' >"$destination/absent" || return 1 + fi + sm_fsync_tree "$destination" || return 1 + sm_fsync "$SM_ROLLBACK_DIR" || return 1 + sm_failpoint "snapshot:$label" +} + +sm_snapshot_all() { + local label + "$SM_BB" mkdir -p "$SM_ROLLBACK_DIR" || return 1 + for label in payload legacy_rc init_rc policy policy_gz bootanim bootanim_gz runtime addon_script addon_dir; do + [ -n "$SM_POLICY_PATH" ] || case "$label" in policy|policy_gz) continue ;; esac + [ "$label" != legacy_rc ] || [ "$SM_SYSTEM_DIR.rc" != "$SM_INIT_PATH" ] || continue + sm_snapshot_one "$label" || return 1 + done + return 0 +} + +sm_original_record() { + local canonical="$1" label="$2" source="$3" existed="$4" metadata="${5:-$3}" + local destination="$SM_STATE_DIR/original/$label" digest=- size=0 mode=- uid=- gid=- context=- + "$SM_BB" mkdir -p "$destination" || return 1 + if [ "$existed" = true ]; then + sm_path_present "$source" || return 1 + "$SM_BB" cp -a "$source" "$destination/data" || return 1 + sm_fsync_tree "$destination/data" || return 1 + digest="$(sm_digest_path "$destination/data")" || return 1 + if [ -f "$destination/data" ]; then size="$($SM_BB stat -c %s "$destination/data")"; fi + mode="0$($SM_BB stat -c %a "$metadata")" + uid="$($SM_BB stat -c %u "$metadata")" + gid="$($SM_BB stat -c %g "$metadata")" + context="$(sm_context "$metadata")" + [ -n "$context" ] || context=- + else + printf 'absent\n' >"$destination/absent" || return 1 + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$canonical" "$existed" "$digest" "$size" "$mode" "$uid" "$gid" "$context" "original/$label/data" >>"$SM_ORIGINAL_FILE.new" || return 1 + sm_fsync_tree "$destination" || return 1 +} + +sm_decompress_original() { + local compressed="$1" destination="$2" + "$SM_BB" gzip -cdf "$compressed" >"$destination" || return 1 + "$SM_BB" chmod --reference="${compressed%.gz}" "$destination" 2>/dev/null || "$SM_BB" chmod 0644 "$destination" + "$SM_BB" chown --reference="${compressed%.gz}" "$destination" 2>/dev/null || "$SM_BB" chown 0:0 "$destination" +} + +sm_prepare_originals() { + local config_real init_real policy_real policy_gz bootanim_real bootanim_gz temp legacy=false conflict + if [ -f "$SM_ORIGINAL_FILE" ]; then + [ -f "$SM_MANIFEST_COPY" ] || { sm_log "! Original backups exist without an install manifest"; return 1; } + return 0 + fi + config_real="$(sm_real_path "$SM_SYSTEM_DIR/config")" || return 1 + [ "$("$SM_BB" sed -n 's/^SYSTEMMODE=//p' "$config_real" 2>/dev/null | "$SM_BB" head -n 1)" = true ] && legacy=true + if sm_path_present "$(sm_real_path "$SM_SYSTEM_DIR")" && [ "$legacy" != true ]; then + sm_log "! Refusing to replace an unowned System Mode payload path" + return 1 + fi + if [ "$legacy" != true ]; then + for conflict in /data/adb/magisk /system/addon.d/99-magisk.sh /system/addon.d/magisk \ + "$SM_SYSTEM_DIR.rc" "$SM_INIT_PATH"; do + if sm_path_present "$(sm_real_path "$conflict")"; then + sm_log "! Refusing to replace unowned path $conflict" + return 1 + fi + done + fi + "$SM_BB" mkdir -p "$SM_STATE_DIR/original" || return 1 + : >"$SM_ORIGINAL_FILE.new" || return 1 + sm_original_record "$SM_SYSTEM_DIR" payload /dev/null false || return 1 + if [ "$SM_SYSTEM_DIR.rc" != "$SM_INIT_PATH" ]; then + sm_original_record "$SM_SYSTEM_DIR.rc" legacy_rc /dev/null false || return 1 + fi + init_real="$(sm_real_path "$SM_INIT_PATH")" || return 1 + if [ "$legacy" = true ] && [ "$SM_SYSTEM_DIR.rc" != "$SM_INIT_PATH" ] && sm_path_present "$init_real"; then + sm_original_record "$SM_INIT_PATH" init_rc "$init_real" true || return 1 + else + sm_original_record "$SM_INIT_PATH" init_rc /dev/null false || return 1 + fi + sm_original_record /data/adb/magisk runtime /dev/null false || return 1 + sm_original_record /system/addon.d/99-magisk.sh addon_script /dev/null false || return 1 + sm_original_record /system/addon.d/magisk addon_dir /dev/null false || return 1 + + if [ -n "$SM_POLICY_PATH" ]; then + policy_real="$(sm_real_path "$SM_POLICY_PATH")" || return 1 + policy_gz="$policy_real.gz" + if [ "$legacy" = true ] && [ -f "$policy_gz" ]; then + temp="$SM_STATE_DIR/.policy.original" + sm_decompress_original "$policy_gz" "$temp" || return 1 + sm_original_record "$SM_POLICY_PATH" policy "$temp" true "$policy_real" || return 1 + "$SM_BB" rm -f "$temp" + else + sm_original_record "$SM_POLICY_PATH" policy "$policy_real" true || return 1 + fi + fi + bootanim_real="$(sm_real_path /system/etc/init/bootanim.rc)" || return 1 + bootanim_gz="$bootanim_real.gz" + if [ "$legacy" = true ] && [ -f "$bootanim_gz" ]; then + temp="$SM_STATE_DIR/.bootanim.original" + sm_decompress_original "$bootanim_gz" "$temp" || return 1 + sm_original_record /system/etc/init/bootanim.rc bootanim "$temp" true "$bootanim_real" || return 1 + "$SM_BB" rm -f "$temp" + elif sm_path_present "$bootanim_real"; then + sm_original_record /system/etc/init/bootanim.rc bootanim "$bootanim_real" true || return 1 + else + sm_original_record /system/etc/init/bootanim.rc bootanim /dev/null false || return 1 + fi + sm_atomic_publish "$SM_ORIGINAL_FILE.new" "$SM_ORIGINAL_FILE" original-inventory || return 1 + sm_fsync_tree "$SM_STATE_DIR/original" || return 1 + return 0 +} + +sm_restore_snapshot() { + local label canonical real source failed=0 rollback="$SM_ROLLBACK_DIR" + sm_update_state ROLLING_BACK || return 1 + for label in addon_dir addon_script runtime bootanim_gz bootanim policy_gz policy init_rc legacy_rc payload; do + [ -n "$SM_POLICY_PATH" ] || case "$label" in policy|policy_gz) continue ;; esac + [ "$label" != legacy_rc ] || [ "$SM_SYSTEM_DIR.rc" != "$SM_INIT_PATH" ] || continue + canonical="$(sm_label_path "$label")" || return 1 + real="$(sm_real_path "$canonical")" || return 1 + source="$SM_ROLLBACK_DIR/$label" + [ -d "$source" ] || { failed=1; break; } + "$SM_BB" rm -rf "$real" || { failed=1; break; } + if [ -f "$source/present" ]; then + "$SM_BB" mkdir -p "$(sm_parent "$real")" || { failed=1; break; } + "$SM_BB" cp -a "$source/data" "$real" || { failed=1; break; } + sm_fsync_tree "$real" || { failed=1; break; } + elif [ ! -f "$source/absent" ]; then + failed=1 + break + fi + sm_fsync_existing_parent "$real" || { failed=1; break; } + sm_failpoint "rollback:$label" || { failed=1; break; } + done + if [ "$failed" != 0 ]; then + sm_update_state FAILED + sm_log "! Automatic rollback failed at ${canonical:-transaction setup}; use the verified external restore" + return 1 + fi + if ! sm_cleanup_staging; then + sm_update_state FAILED + sm_log "! Transaction staging cleanup failed; use the verified external restore" + return 1 + fi + if ! sm_restore_state_metadata; then + sm_update_state FAILED + sm_log "! Transaction metadata recovery failed; use the verified external restore" + return 1 + fi + "$SM_BB" rm -rf "$rollback" || return 1 + sm_fsync "$SM_STATE_DIR/rollback" "$SM_STATE_DIR" || return 1 + return 0 +} + +sm_abort_transaction() { + [ -f "$SM_TRANSACTION_FILE" ] || return 0 + sm_load_transaction || return 1 + case "$SM_STATE" in + PREFLIGHTED) sm_restore_preflight ;; + STAGED|COMMITTED|ROLLBACK_REQUIRED|ROLLING_BACK) + [ "$SM_STATE" = ROLLING_BACK ] || sm_update_state ROLLBACK_REQUIRED || return 1 + sm_restore_snapshot + ;; + BOOT_VERIFIED|UNINSTALLED) return 0 ;; + FAILED) return 1 ;; + esac +} + +sm_verify_owned() { + local path digest size mode uid gid context kind real actual actual_size actual_mode actual_uid actual_gid actual_context + [ -f "$SM_OWNERSHIP_FILE" ] || { sm_log "! System Mode ownership inventory is missing"; return 1; } + while IFS="$SM_TAB" read -r path digest size mode uid gid context kind; do + [ -n "$path" ] || continue + real="$(sm_real_path "$path")" || return 1 + case "$kind" in + file) [ -f "$real" ] || { sm_log "! Owned file is missing: $path"; return 1; }; actual="$(sm_sha256_file "$real")" ;; + link) [ -L "$real" ] || { sm_log "! Owned link is missing: $path"; return 1; }; actual="$(sm_digest_path "$real")" ;; + *) sm_log "! Invalid ownership record for $path"; return 1 ;; + esac + [ "$actual" = "$digest" ] || { sm_log "! Owned path digest changed: $path"; return 1; } + actual_size="$($SM_BB stat -c %s "$real" 2>/dev/null)" || actual_size=0 + actual_mode="0$($SM_BB stat -c %a "$real")" || return 1 + actual_uid="$($SM_BB stat -c %u "$real")" || return 1 + actual_gid="$($SM_BB stat -c %g "$real")" || return 1 + actual_context="$(sm_context "$real")" + [ -n "$actual_context" ] || actual_context=- + [ "$actual_size" = "$size" ] && [ "$actual_mode" = "$mode" ] && + [ "$actual_uid" = "$uid" ] && [ "$actual_gid" = "$gid" ] && + [ "$actual_context" = "$context" ] || { + sm_log "! Owned path metadata changed: $path" + return 1 + } + done <"$SM_OWNERSHIP_FILE" + return 0 +} + +sm_recover_pending() { + local current_boot + [ -f "$SM_TRANSACTION_FILE" ] || return 0 + sm_load_transaction || { sm_log "! Invalid persistent System Mode transaction"; return 1; } + case "$SM_STATE" in + UNINSTALLED|BOOT_VERIFIED) return 0 ;; + PREFLIGHTED) sm_restore_preflight ;; + STAGED|ROLLBACK_REQUIRED|ROLLING_BACK) + sm_log "- Recovering interrupted System Mode transaction" + sm_restore_snapshot + ;; + COMMITTED) + current_boot="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" + if [ "$current_boot" = "$SM_COMMIT_BOOT_ID" ]; then + sm_log "! Reboot once to verify the committed System Mode installation" + return 2 + fi + if [ "$(getprop sys.boot_completed)" = 1 ]; then + sm_log "! Committed System Mode payload did not pass boot verification; rolling back" + sm_update_state ROLLBACK_REQUIRED || return 1 + sm_restore_snapshot + else + return 2 + fi + ;; + FAILED) + sm_log "! System Mode transaction is failed; use the verified external restore" + return 1 + ;; + esac +} + +sm_validate_installed_state() { + local manifest_real + manifest_real="$(sm_real_path "$SM_SYSTEM_DIR/install-manifest.json")" || return 1 + [ -f "$manifest_real" ] && [ -f "$SM_MANIFEST_COPY" ] || { + sm_log "! Existing System Mode manifest is incomplete" + return 1 + } + [ "$(sm_sha256_file "$manifest_real")" = "$(sm_sha256_file "$SM_MANIFEST_COPY")" ] || { + sm_log "! Existing System Mode manifest changed" + return 1 + } + sm_verify_owned || { + sm_log "! Existing System Mode payload changed" + return 1 + } + sm_assert_no_unowned_files || { + sm_log "! Existing System Mode roots contain unowned files" + return 1 + } + sm_validate_originals || { + sm_log "! Existing System Mode original backup changed" + return 1 + } + return 0 +} + +sm_begin_transaction() { + local upgrading=false prior_adapter prior_init prior_policy prior_policy_source prior_runtime prior_selinux + local requested_source_commit="${KITSUNE_SOURCE_COMMIT:-}" + local requested_upstream_base="${KITSUNE_UPSTREAM_BASE:-}" + local requested_product_version="${MAGISK_VER:-unknown-kitsune}" + SM_ARTIFACT_PATH="${1:-}" + case "$requested_source_commit" in *[!a-f0-9]*|'') sm_log "! Missing full source identity"; return 1 ;; esac + [ "${#requested_source_commit}" -eq 40 ] || { sm_log "! Invalid source identity"; return 1; } + case "$requested_upstream_base" in *[!a-f0-9]*|'') sm_log "! Missing full upstream identity"; return 1 ;; esac + [ "${#requested_upstream_base}" -eq 40 ] || { sm_log "! Invalid upstream identity"; return 1; } + SM_STATE= + sm_recover_pending + case $? in 0) ;; 2) return 1 ;; *) return 1 ;; esac + if [ -f "$SM_TRANSACTION_FILE" ]; then + sm_load_transaction || return 1 + else + SM_STATE= + fi + if [ "${SM_STATE:-}" = BOOT_VERIFIED ]; then + sm_validate_installed_state || { + sm_log "! Refusing to upgrade a modified System Mode installation" + return 1 + } + upgrading=true + prior_adapter="$SM_ADAPTER_ID" + prior_init="$SM_INIT_PATH" + prior_policy="$SM_POLICY_PATH" + prior_policy_source="$SM_POLICY_SOURCE" + prior_runtime="$SM_RUNTIME_PATH" + prior_selinux="$SM_SELINUX_STRATEGY" + fi + sm_validate_authorization || return 1 + sm_select_strategies || return 1 + if [ "$upgrading" = true ] && + { [ "$SM_ADAPTER_ID" != "$prior_adapter" ] || [ "$SM_INIT_PATH" != "$prior_init" ] || + [ "$SM_POLICY_PATH" != "$prior_policy" ] || [ "$SM_POLICY_SOURCE" != "$prior_policy_source" ] || + [ "$SM_RUNTIME_PATH" != "$prior_runtime" ] || [ "$SM_SELINUX_STRATEGY" != "$prior_selinux" ]; }; then + sm_log "! Refusing to change System Mode adapter or boot strategy during upgrade" + return 1 + fi + # Recovery loads the prior receipt into these globals. Reapply the identity + # of the artifact being installed only after the prior state is validated. + SM_SOURCE_COMMIT="$requested_source_commit" + SM_UPSTREAM_BASE="$requested_upstream_base" + SM_PRODUCT_VERSION="$requested_product_version" + [ -f "$SM_ARTIFACT_PATH" ] || { sm_log "! Exact install artifact is unavailable"; return 1; } + SM_ARTIFACT_SHA256="$(sm_sha256_file "$SM_ARTIFACT_PATH")" || return 1 + case "$SM_ARTIFACT_SHA256" in *[!a-f0-9]*|'') sm_log "! Cannot hash exact install artifact"; return 1 ;; esac + [ "${#SM_ARTIFACT_SHA256}" -eq 64 ] || { sm_log "! Invalid install artifact digest"; return 1; } + if [ -f "$SM_TRANSACTION_FILE" ]; then + SM_PRIOR_STATE="$(sm_get STATE "$SM_TRANSACTION_FILE")" + SM_INSTALL_ID="$(sm_get INSTALL_ID "$SM_TRANSACTION_FILE")" + else + SM_PRIOR_STATE=UNINSTALLED + SM_INSTALL_ID="$(cat /proc/sys/kernel/random/uuid 2>/dev/null)" + fi + case "$SM_PRIOR_STATE" in BOOT_VERIFIED|UNINSTALLED) ;; *) SM_PRIOR_STATE=UNINSTALLED ;; esac + sm_valid_uuid "$SM_INSTALL_ID" || { sm_log "! Unable to create install identity"; return 1; } + SM_TRANSACTION_ID="$(cat /proc/sys/kernel/random/uuid 2>/dev/null)" + sm_valid_uuid "$SM_TRANSACTION_ID" || { sm_log "! Unable to create transaction identity"; return 1; } + SM_ROLLBACK_DIR="$SM_STATE_DIR/rollback/$SM_TRANSACTION_ID" + SM_STAGING_PATH="$(sm_parent "$SM_SYSTEM_DIR")/.magisk.kitsune-stage-$SM_TRANSACTION_ID" + SM_COMMIT_BOOT_ID= + "$SM_BB" rm -rf "$SM_STATE_DIR/rollback" || return 1 + "$SM_BB" mkdir -p "$SM_ROLLBACK_DIR" || return 1 + sm_snapshot_state_metadata || return 1 + SM_STATE=PREFLIGHTED + sm_write_transaction || return 1 + sm_failpoint preflighted || return 1 + sm_prepare_originals || return 1 + sm_snapshot_all || return 1 + : >"$SM_JOURNAL_FILE" || return 1 + sm_fsync "$SM_JOURNAL_FILE" "$SM_STATE_DIR" || return 1 + sm_update_state STAGED || return 1 + sm_failpoint staged +} + +sm_restore_legacy_bootanim() { + local target compressed staged before after + target="$(sm_real_path /system/etc/init/bootanim.rc)" || return 1 + compressed="$target.gz" + [ -f "$compressed" ] || return 0 + before="$(sm_sha256_file "$target")" + staged="$target.kitsune-stock-new" + "$SM_BB" gzip -cdf "$compressed" >"$staged" || return 1 + "$SM_BB" chmod --reference="$target" "$staged" 2>/dev/null || "$SM_BB" chmod 0644 "$staged" + "$SM_BB" chown --reference="$target" "$staged" 2>/dev/null || "$SM_BB" chown 0:0 "$staged" + chcon --reference="$target" "$staged" 2>/dev/null || true + sm_atomic_publish "$staged" "$target" legacy-init-restored || return 1 + after="$(sm_sha256_file "$target")" + printf 'legacy-init\treplace\t/system/etc/init/bootanim.rc\t%s\t%s\ttrue\n' "$before" "$after" >>"$SM_JOURNAL_FILE" || return 1 + sm_fsync "$SM_JOURNAL_FILE" || return 1 +} + +sm_add_owned_file() { + local canonical="$1" real="$2" digest size mode uid gid context kind=file + if [ -L "$real" ]; then kind="link"; else [ -f "$real" ] || return 0; fi + digest="$(sm_digest_path "$real")" || return 1 + size="$($SM_BB stat -c %s "$real" 2>/dev/null)" || size=0 + mode="0$($SM_BB stat -c %a "$real")" || return 1 + uid="$($SM_BB stat -c %u "$real")" || return 1 + gid="$($SM_BB stat -c %g "$real")" || return 1 + context="$(sm_context "$real")" + [ -n "$context" ] || context=- + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$canonical" "$digest" "$size" "$mode" "$uid" "$gid" "$context" "$kind" >>"$SM_OWNERSHIP_FILE.new" +} + +sm_collect_owned_tree() { + local canonical_root="$1" real_root="$2" item relative + [ -d "$real_root" ] || return 0 + ( + cd "$real_root" || exit 1 + "$SM_BB" find . \( -type f -o -type l \) -print | "$SM_BB" sort + ) | while IFS= read -r item; do + relative="${item#./}" + [ "$canonical_root/$relative" = "$SM_SYSTEM_DIR/install-manifest.json" ] && continue + sm_add_owned_file "$canonical_root/$relative" "$real_root/$relative" || exit 1 + done +} + +sm_collect_ownership() { + local payload_real runtime_real init_real policy_real addon_real + : >"$SM_OWNERSHIP_FILE.new" || return 1 + payload_real="$(sm_real_path "$SM_SYSTEM_DIR")" || return 1 + runtime_real=/data/adb/magisk + sm_collect_owned_tree "$SM_SYSTEM_DIR" "$payload_real" || return 1 + sm_collect_owned_tree /data/adb/magisk "$runtime_real" || return 1 + init_real="$(sm_real_path "$SM_INIT_PATH")" || return 1 + sm_add_owned_file "$SM_INIT_PATH" "$init_real" || return 1 + if [ -n "$SM_POLICY_PATH" ]; then + policy_real="$(sm_real_path "$SM_POLICY_PATH")" || return 1 + sm_add_owned_file "$SM_POLICY_PATH" "$policy_real" || return 1 + fi + addon_real="$(sm_real_path /system/addon.d/99-magisk.sh)" || return 1 + sm_add_owned_file /system/addon.d/99-magisk.sh "$addon_real" || return 1 + sm_atomic_publish "$SM_OWNERSHIP_FILE.new" "$SM_OWNERSHIP_FILE" ownership-inventory +} + +sm_lookup_original() { + local wanted="$1" path existed digest size mode uid gid context backup + while IFS="$SM_TAB" read -r path existed digest size mode uid gid context backup; do + [ "$path" = "$wanted" ] || continue + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$existed" "$digest" "$size" "$mode" "$uid" "$gid" "$context" "$backup" + return 0 + done <"$SM_ORIGINAL_FILE" + return 1 +} + +sm_generate_journal() { + local path digest size mode uid gid context kind original before operation sequence=0 + : >"$SM_JOURNAL_FILE.new" || return 1 + while IFS="$SM_TAB" read -r path digest size mode uid gid context kind; do + [ -n "$path" ] || continue + original="$(sm_lookup_original "$path" 2>/dev/null)" || original= + before="$(printf '%s' "$original" | "$SM_BB" cut -f2)" + if [ -n "$before" ] && [ "$before" != - ]; then operation=replace; else operation=create; before=-; fi + sequence=$((sequence + 1)) + printf '%s\tpublish\t%s\t%s\t%s\t%s\ttrue\n' "$sequence" "$operation" "$path" "$before" "$digest" >>"$SM_JOURNAL_FILE.new" || return 1 + done <"$SM_OWNERSHIP_FILE" + sm_atomic_publish "$SM_JOURNAL_FILE.new" "$SM_JOURNAL_FILE" journal-published +} + +sm_json_value_or_null() { + if [ -n "$1" ] && [ "$1" != - ]; then printf '"%s"' "$(sm_json_escape "$1")"; else printf 'null'; fi +} + +sm_generate_manifest() { + local output="$SM_STATE_DIR/.install-manifest.json.new" first path digest size mode uid gid context kind + local existed backup sequence boundary operation before after committed abi old_ifs + { + printf '{\n' + printf ' "schema_version": 1,\n' + printf ' "install_id": "%s",\n' "$(sm_json_escape "$SM_INSTALL_ID")" + printf ' "state": "COMMITTED",\n' + printf ' "product": {"name": "KitsuneMagisk", "version": "%s", "source_commit": "%s", "upstream_base": "%s", "artifact_sha256": "%s"},\n' \ + "$(sm_json_escape "$SM_PRODUCT_VERSION")" "$SM_SOURCE_COMMIT" "$SM_UPSTREAM_BASE" "$SM_ARTIFACT_SHA256" + printf ' "target": {"adapter_id": "%s", "fingerprint_sha256": "%s", "api": %s, "abis": [' \ + "$(sm_json_escape "$SM_ADAPTER_ID")" "$SM_FINGERPRINT_SHA256" "$(getprop ro.build.version.sdk)" + first=true + old_ifs="$IFS"; IFS=, + for abi in $SM_TARGET_ABIS; do + [ "$first" = true ] || printf ', ' + printf '"%s"' "$(sm_json_escape "$abi")" + first=false + done + IFS="$old_ifs" + printf ']},\n' + printf ' "strategies": {"init": "%s", "selinux": "%s:%s", "runtime_tmpfs": "%s"},\n' \ + "$(sm_json_escape "$SM_INIT_PATH")" "$(sm_json_escape "$SM_SELINUX_STRATEGY")" "$(sm_json_escape "$SM_POLICY_SOURCE")" "$(sm_json_escape "$SM_RUNTIME_PATH")" + printf ' "payload": [\n' + first=true + while IFS="$SM_TAB" read -r path digest size mode uid gid context kind; do + [ -n "$path" ] || continue + [ "$first" = true ] || printf ',\n' + printf ' {"path": "%s", "sha256": "%s", "size": %s, "mode": "%s", "uid": %s, "gid": %s, "selinux_context": ' \ + "$(sm_json_escape "$path")" "$digest" "$size" "$mode" "$uid" "$gid" + sm_json_value_or_null "$context" + printf '}' + first=false + done <"$SM_OWNERSHIP_FILE" + printf '\n ],\n' + printf ' "originals": [\n' + first=true + while IFS="$SM_TAB" read -r path existed digest size mode uid gid context backup; do + [ -n "$path" ] || continue + [ "$first" = true ] || printf ',\n' + printf ' {"path": "%s", "sha256": ' "$(sm_json_escape "$path")" + sm_json_value_or_null "$digest" + printf ', "size": %s, "mode": ' "$size" + sm_json_value_or_null "$mode" + printf ', "uid": ' + if [ "$uid" = - ]; then printf 'null'; else printf '%s' "$uid"; fi + printf ', "gid": ' + if [ "$gid" = - ]; then printf 'null'; else printf '%s' "$gid"; fi + printf ', "selinux_context": ' + sm_json_value_or_null "$context" + printf ', "existed": %s, "backup_path": ' "$existed" + if [ "$existed" = true ]; then printf '"%s/%s"' "$SM_STATE_DIR" "$(sm_json_escape "$backup")"; else printf 'null'; fi + printf '}' + first=false + done <"$SM_ORIGINAL_FILE" + printf '\n ],\n' + printf ' "backup": {"external": true, "location": "%s", "sha256": "%s", "restore_command": "%s"},\n' \ + "$(sm_json_escape "$SM_BACKUP_LOCATION")" "$SM_BACKUP_SHA256" "$(sm_json_escape "$SM_RESTORE_COMMAND")" + printf ' "journal": [\n' + first=true + while IFS="$SM_TAB" read -r sequence boundary operation path before after committed; do + [ -n "$sequence" ] || continue + [ "$first" = true ] || printf ',\n' + printf ' {"sequence": %s, "boundary": "%s", "operation": "%s", "target": "%s", "before_sha256": ' \ + "$sequence" "$(sm_json_escape "$boundary")" "$operation" "$(sm_json_escape "$path")" + sm_json_value_or_null "$before" + printf ', "after_sha256": ' + sm_json_value_or_null "$after" + printf ', "committed": %s}' "$committed" + first=false + done <"$SM_JOURNAL_FILE" + printf '\n ]\n}\n' + } >"$output" || return 1 + "$SM_BB" grep -q '"schema_version": 1' "$output" && "$SM_BB" grep -q '"state": "COMMITTED"' "$output" || return 1 + sm_fsync "$output" || return 1 +} + +sm_remove_legacy_sidecars() { + local path real + for path in /system/etc/init/bootanim.rc.gz "$SM_POLICY_PATH.gz"; do + [ "$path" != .gz ] || continue + real="$(sm_real_path "$path")" || return 1 + if sm_path_present "$real"; then + "$SM_BB" rm -f "$real" || return 1 + sm_fsync "$(sm_parent "$real")" || return 1 + sm_failpoint "remove-sidecar:$path" || return 1 + fi + done +} + +sm_commit_transaction() { + local manifest_real staged_manifest + sm_load_transaction || return 1 + [ "$SM_STATE" = STAGED ] || return 1 + sm_remove_legacy_sidecars || return 1 + sm_collect_ownership || return 1 + sm_generate_journal || return 1 + sm_generate_manifest || return 1 + manifest_real="$(sm_real_path "$SM_SYSTEM_DIR/install-manifest.json")" || return 1 + staged_manifest="$(sm_parent "$manifest_real")/.install-manifest.json.new" + "$SM_BB" cp -a "$SM_STATE_DIR/.install-manifest.json.new" "$staged_manifest" || return 1 + sm_atomic_publish "$staged_manifest" "$manifest_real" manifest-published || return 1 + "$SM_BB" cp -a "$manifest_real" "$SM_STATE_DIR/.install-manifest.copy.new" || return 1 + sm_atomic_publish "$SM_STATE_DIR/.install-manifest.copy.new" "$SM_MANIFEST_COPY" manifest-copy-published || return 1 + # Neither durable manifest depends on its construction copy after both + # publications have reached disk. Remove all staging names before exposing + # COMMITTED so a completed transaction is distinguishable from an interrupted + # one without relying on a later install or boot to clean it up. + sm_cleanup_staging || return 1 + SM_COMMIT_BOOT_ID="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" + sm_update_state COMMITTED || return 1 + sm_failpoint committed || return 1 + "$SM_BB" rm -f "$SM_AUTHORIZATION_FILE" || sm_log "W: Recovery authorization was not consumed" + sm_fsync /data/local/tmp 2>/dev/null || sm_log "W: Authorization cleanup fsync failed" + return 0 +} + +sm_update_manifest_state() { + local state="$1" manifest_real staged + manifest_real="$(sm_real_path "$SM_SYSTEM_DIR/install-manifest.json")" || return 1 + [ -f "$manifest_real" ] || return 1 + staged="$(sm_parent "$manifest_real")/.install-manifest.state-new" + "$SM_BB" sed "s/\"state\": \"COMMITTED\"/\"state\": \"$state\"/" "$manifest_real" >"$staged" || return 1 + "$SM_BB" grep -q "\"state\": \"$state\"" "$staged" || return 1 + sm_atomic_publish "$staged" "$manifest_real" "manifest-state:$state" || return 1 + "$SM_BB" cp -a "$manifest_real" "$SM_STATE_DIR/.install-manifest.copy.new" || return 1 + sm_atomic_publish "$SM_STATE_DIR/.install-manifest.copy.new" "$SM_MANIFEST_COPY" "manifest-copy-state:$state" +} + +sm_verify_boot() { + local boot_id staged + sm_load_transaction || return 1 + [ "$SM_STATE" = COMMITTED ] || { [ "$SM_STATE" = BOOT_VERIFIED ]; return; } + if ! sm_verify_owned; then + sm_update_state ROLLBACK_REQUIRED + sm_log "! System Mode boot verification failed" + return 1 + fi + [ -x "$SM_RUNTIME_PATH/magisk" ] || { + sm_log "! System Mode runtime was not populated at $SM_RUNTIME_PATH" + sm_update_state ROLLBACK_REQUIRED + return 1 + } + boot_id="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" + [ -n "$boot_id" ] || { sm_log "! Unable to read the boot identity"; return 1; } + [ "$boot_id" != "$SM_COMMIT_BOOT_ID" ] || { sm_log "! A new boot is required before verification"; return 1; } + staged="$SM_STATE_DIR/.boot-verified.env.new" + { + printf 'SCHEMA_VERSION=1\n' + printf 'INSTALL_ID=%s\n' "$SM_INSTALL_ID" + printf 'BOOT_ID=%s\n' "$boot_id" + printf 'INIT_PATH=%s\n' "$SM_INIT_PATH" + printf 'RUNTIME_PATH=%s\n' "$SM_RUNTIME_PATH" + } >"$staged" || return 1 + sm_atomic_publish "$staged" "$SM_BOOT_PROOF" boot-proof-published || { + sm_log "! Unable to persist System Mode boot proof" + sm_update_state ROLLBACK_REQUIRED + return 1 + } + if ! sm_prepare_persistent_mounts; then + sm_restore_persistent_mounts || true + sm_log "! Unable to prepare persistent files for boot verification" + sm_update_state ROLLBACK_REQUIRED + return 1 + fi + if ! sm_update_manifest_state BOOT_VERIFIED; then + sm_restore_persistent_mounts || true + sm_log "! Unable to publish the boot-verified manifest" + sm_update_state ROLLBACK_REQUIRED + return 1 + fi + if ! sm_restore_persistent_mounts; then + sm_log "! Unable to restore persistent filesystem modes after boot verification" + sm_update_state ROLLBACK_REQUIRED + return 1 + fi + sm_update_state BOOT_VERIFIED || { sm_log "! Unable to publish the boot-verified state"; return 1; } + "$SM_BB" rm -rf "$SM_ROLLBACK_DIR" || sm_log "W: Boot-verified rollback cleanup is incomplete" + sm_fsync "$SM_STATE_DIR/rollback" "$SM_STATE_DIR" 2>/dev/null || true + return 0 +} + +sm_restore_originals() { + local path existed digest size mode uid gid context backup real source actual failed=0 + while IFS="$SM_TAB" read -r path existed digest size mode uid gid context backup; do + [ -n "$path" ] || continue + case "$path" in + "$SM_SYSTEM_DIR"|"$SM_SYSTEM_DIR.rc"|"$SM_INIT_PATH"|"$SM_POLICY_PATH"|/system/etc/init/bootanim.rc|/data/adb/magisk|/system/addon.d/99-magisk.sh|/system/addon.d/magisk) ;; + *) sm_log "! Refusing unrecognized original path $path"; return 1 ;; + esac + real="$(sm_real_path "$path")" || return 1 + if [ "$path" = /system/etc/init/bootanim.rc ]; then + continue + fi + "$SM_BB" rm -rf "$real" || { failed=1; break; } + if [ "$existed" = true ]; then + source="$SM_STATE_DIR/$backup" + sm_path_present "$source" || { failed=1; break; } + actual="$(sm_digest_path "$source")" + [ "$actual" = "$digest" ] || { failed=1; break; } + "$SM_BB" mkdir -p "$(sm_parent "$real")" || { failed=1; break; } + "$SM_BB" cp -a "$source" "$real" || { failed=1; break; } + "$SM_BB" chmod "${mode#0}" "$real" || { failed=1; break; } + "$SM_BB" chown "$uid:$gid" "$real" || { failed=1; break; } + [ "$context" = - ] || chcon "$context" "$real" 2>/dev/null || { failed=1; break; } + sm_fsync_tree "$real" || { failed=1; break; } + fi + sm_fsync_existing_parent "$real" || { failed=1; break; } + sm_failpoint "uninstall:$path" || { failed=1; break; } + done <"$SM_ORIGINAL_FILE" + [ "$failed" = 0 ] +} + +sm_validate_originals() { + local path existed digest size mode uid gid context backup real source actual + [ -f "$SM_ORIGINAL_FILE" ] || return 1 + while IFS="$SM_TAB" read -r path existed digest size mode uid gid context backup; do + [ -n "$path" ] || continue + if [ "$existed" = true ]; then + source="$SM_STATE_DIR/$backup" + sm_path_present "$source" || return 1 + [ "$(sm_digest_path "$source")" = "$digest" ] || return 1 + fi + if [ "$path" = /system/etc/init/bootanim.rc ]; then + real="$(sm_real_path "$path")" || return 1 + if [ "$existed" = true ]; then + sm_path_present "$real" || return 1 + actual="$(sm_digest_path "$real")" + [ "$actual" = "$digest" ] || return 1 + else + ! sm_path_present "$real" || return 1 + fi + fi + done <"$SM_ORIGINAL_FILE" + return 0 +} + +sm_assert_no_unowned_files() { + local root real item canonical + for root in "$SM_SYSTEM_DIR" /data/adb/magisk; do + real="$(sm_real_path "$root")" || return 1 + [ -d "$real" ] || continue + ( + cd "$real" || exit 1 + "$SM_BB" find . \( -type f -o -type l \) -print | "$SM_BB" sort + ) | while IFS= read -r item; do + canonical="$root/${item#./}" + [ "$canonical" = "$SM_SYSTEM_DIR/install-manifest.json" ] && continue + "$SM_BB" awk -F '\t' -v path="$canonical" '$1 == path { found=1 } END { exit !found }' "$SM_OWNERSHIP_FILE" || exit 1 + done || return 1 + done + return 0 +} + +sm_uninstall() { + sm_load_transaction || { sm_log "! A versioned System Mode manifest is required for uninstall"; return 1; } + case "$SM_STATE" in + PREFLIGHTED|STAGED|ROLLBACK_REQUIRED|ROLLING_BACK) + sm_log "- Recovering interrupted System Mode uninstall before retry" + sm_recover_pending || return 1 + sm_load_transaction || return 1 + ;; + esac + case "$SM_STATE" in BOOT_VERIFIED|COMMITTED) ;; *) sm_log "! System Mode is not in an uninstallable state"; return 1 ;; esac + sm_validate_installed_state || { sm_log "! Exact System Mode uninstall refused"; return 1; } + + SM_PRIOR_STATE=BOOT_VERIFIED + SM_TRANSACTION_ID="$(cat /proc/sys/kernel/random/uuid 2>/dev/null)" + SM_ROLLBACK_DIR="$SM_STATE_DIR/rollback/$SM_TRANSACTION_ID" + SM_STAGING_PATH="$(sm_parent "$SM_SYSTEM_DIR")/.magisk.kitsune-stage-$SM_TRANSACTION_ID" + SM_COMMIT_BOOT_ID= + "$SM_BB" rm -rf "$SM_STATE_DIR/rollback" || return 1 + "$SM_BB" mkdir -p "$SM_ROLLBACK_DIR" || return 1 + sm_snapshot_state_metadata || return 1 + sm_update_state PREFLIGHTED || return 1 + sm_snapshot_all || { sm_update_state FAILED; return 1; } + sm_update_state STAGED || return 1 + if ! sm_restore_originals; then + sm_update_state ROLLBACK_REQUIRED + sm_restore_snapshot + return 1 + fi + if ! "$SM_BB" rm -f "$SM_MANIFEST_COPY" "$SM_OWNERSHIP_FILE" "$SM_ORIGINAL_FILE" \ + "$SM_JOURNAL_FILE" "$SM_BOOT_PROOF" || + ! "$SM_BB" rm -rf "$SM_STATE_DIR/original" || + ! sm_fsync "$SM_STATE_DIR"; then + sm_update_state ROLLBACK_REQUIRED + sm_restore_snapshot + return 1 + fi + SM_PRIOR_STATE=UNINSTALLED + if ! sm_update_state UNINSTALLED; then + sm_update_state ROLLBACK_REQUIRED + sm_restore_snapshot + return 1 + fi + "$SM_BB" rm -rf "$SM_ROLLBACK_DIR" || sm_log "W: Uninstall rollback cleanup is incomplete" + sm_fsync "$SM_STATE_DIR/rollback" "$SM_STATE_DIR" 2>/dev/null || true + return 0 +} diff --git a/scripts/system_mode_verify.sh b/scripts/system_mode_verify.sh new file mode 100644 index 000000000..9f2366acc --- /dev/null +++ b/scripts/system_mode_verify.sh @@ -0,0 +1,91 @@ +#!/system/bin/sh +# shellcheck disable=SC1090,SC2034,SC2093 + +# MuMu can leave an init-launched shell blocked when it remounts read-only the +# same filesystem from which that shell is still reading its script. Relocate +# this small verifier to boot tmpfs before it can change a persistent mount. +if [ "${KITSUNE_SYSTEM_MODE_VERIFY_TMPFS:-}" != 1 ]; then + KITSUNE_SYSTEM_MODE_VERIFY_DIR="/dev/.kitsune-system-mode-verify.$$" + KITSUNE_SYSTEM_MODE_VERIFY_COPY="$KITSUNE_SYSTEM_MODE_VERIFY_DIR/system_mode_verify.sh" + mkdir "$KITSUNE_SYSTEM_MODE_VERIFY_DIR" || exit 1 + chmod 0700 "$KITSUNE_SYSTEM_MODE_VERIFY_DIR" || { rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR"; exit 1; } + cp "$0" "$KITSUNE_SYSTEM_MODE_VERIFY_COPY" || { rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR"; exit 1; } + chmod 0700 "$KITSUNE_SYSTEM_MODE_VERIFY_COPY" || { rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR"; exit 1; } + export KITSUNE_SYSTEM_MODE_VERIFY_TMPFS=1 KITSUNE_SYSTEM_MODE_VERIFY_DIR + exec /system/bin/sh "$KITSUNE_SYSTEM_MODE_VERIFY_COPY" + rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR" + exit 1 +fi + +verify_exit() { + local result="$1" + rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR" + exit "$result" +} + +SYSTEM_PAYLOAD=/system/etc/init/magisk +TRANSACTION="$SYSTEM_PAYLOAD/system_mode_transaction.sh" +BUSYBOX=/data/adb/magisk/busybox + +[ -f "$TRANSACTION" ] && [ -x "$BUSYBOX" ] || verify_exit 1 +ui_print() { log -t KitsuneSystemMode -- "$1"; } +. "$TRANSACTION" || verify_exit 1 +sm_configure "$SYSTEM_PAYLOAD" / "$SYSTEM_PAYLOAD" "$BUSYBOX" || verify_exit 1 +sm_load_transaction || verify_exit 1 +case "$SM_STATE" in + COMMITTED) + if sm_verify_boot; then + verify_exit 0 + fi + # Verification failures transition to ROLLBACK_REQUIRED. Recover during + # this same completed boot instead of depending on another reboot. + sm_load_transaction || verify_exit 1 + [ "$SM_STATE" = ROLLBACK_REQUIRED ] || verify_exit 1 + ;; + BOOT_VERIFIED) + sm_verify_boot + verify_exit $? + ;; + PREFLIGHTED|STAGED|ROLLBACK_REQUIRED|ROLLING_BACK) + ;; + UNINSTALLED) + verify_exit 0 + ;; + FAILED) + ui_print "! System Mode recovery requires the verified external restore" + verify_exit 1 + ;; +esac + +# Rollback can remove /data/adb/magisk. Keep the recovery applet on the boot +# tmpfs, with its required BusyBox basename, until every reverse operation has +# completed. +RECOVERY_DIR="/dev/.kitsune-system-mode.$$" +RECOVERY_BB="$RECOVERY_DIR/busybox" +mkdir "$RECOVERY_DIR" || verify_exit 1 +chmod 0700 "$RECOVERY_DIR" || { rm -rf "$RECOVERY_DIR"; verify_exit 1; } +cp "$BUSYBOX" "$RECOVERY_BB" || { rm -rf "$RECOVERY_DIR"; verify_exit 1; } +chmod 0700 "$RECOVERY_BB" || { rm -rf "$RECOVERY_DIR"; verify_exit 1; } +SM_BB="$RECOVERY_BB" +# The daemon can update /data/adb/magisk during boot-complete handling. Stop it +# before restoring that exact tree; this verifier remains root and immediately +# reboots after recovery. +if [ -x "$SM_RUNTIME_PATH/magisk" ]; then + "$SM_RUNTIME_PATH/magisk" --stop || ui_print "W: Magisk daemon stop failed before rollback" +fi +if ! sm_prepare_persistent_mounts; then + sm_restore_persistent_mounts || true + "$RECOVERY_BB" rm -rf "$RECOVERY_DIR" + verify_exit 1 +fi +if sm_recover_pending; then + sm_restore_persistent_mounts || ui_print "W: Rollback filesystem mode restoration failed" + "$RECOVERY_BB" sync + "$RECOVERY_BB" rm -rf "$KITSUNE_SYSTEM_MODE_VERIFY_DIR" + /system/bin/setprop sys.powerctl reboot 2>/dev/null + "$RECOVERY_BB" sleep 2 + "$RECOVERY_BB" reboot -f +fi +sm_restore_persistent_mounts || ui_print "W: Rollback filesystem mode restoration failed" +"$RECOVERY_BB" rm -rf "$RECOVERY_DIR" +verify_exit 1 diff --git a/scripts/uninstaller.sh b/scripts/uninstaller.sh index e493b998d..2f692e720 100644 --- a/scripts/uninstaller.sh +++ b/scripts/uninstaller.sh @@ -40,7 +40,21 @@ mount_partitions check_data $DATA_DE || abort "! Cannot access /data, please uninstall with the Magisk app" get_flags -find_boot_image + +SYSTEM_MODE_UNINSTALL=false +SYSTEM_MODE_RECEIPT_STATE="$(grep_prop STATE /data/adb/kitsune/system-mode/transaction.env)" +if [ "$(grep_prop SYSTEMMODE /system/etc/init/magisk/config)" = "true" ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = BOOT_VERIFIED ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = COMMITTED ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = PREFLIGHTED ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = STAGED ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = ROLLBACK_REQUIRED ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = ROLLING_BACK ] || + [ "$SYSTEM_MODE_RECEIPT_STATE" = FAILED ]; then + SYSTEM_MODE_UNINSTALL=true +else + find_boot_image +fi backup_restore(){ test -f "${1}.gz" || { test -f "$1" && gzip -k "$1"; } @@ -59,7 +73,7 @@ api_level_arch_detect ui_print "- Device platform: $ABI" -if ( [ -z "$(grep_prop SHA1 "$MAGISKTMP/.magisk/config")" ] && $BOOTMODE ) || [ "$(grep_prop SYSTEMMODE /system/etc/init/magisk/config)" == "true" ]; then +if $SYSTEM_MODE_UNINSTALL; then # Use kernel trick to clean up mirrors automatically when installer completed MIRRORDIR="/proc/$$/attr" @@ -108,7 +122,7 @@ if $BOOTMODE; then ln -fs ./system_root/odm "$ODM_DIR" fi else - local MIRRORDIR="/" ROOTDIR SYSTEMDIR VENDORDIR + MIRRORDIR="/" ROOTDIR="$MIRRORDIR/system_root" SYSTEMDIR="$MIRRORDIR/system" VENDORDIR="$MIRRORDIR/vendor" @@ -122,25 +136,17 @@ mount -o rw,remount /system || mount -o rw,remount / mount -o rw,remount /system_root mount -o rw,remount /vendor mount -o rw,remount /odm - -for file in /vendor/etc/selinux/precompiled_sepolicy /odm/etc/selinux/precompiled_sepolicy /system/etc/selinux/precompiled_sepolicy /system_root/sepolicy /system_root/sepolicy_debug /system_root/sepolicy.unlocked; do - if [ -f "$MIRRORDIR$file" ]; then - sepol="$file" - break - fi -done - -if [ ! -z "$sepol" ]; then - ui_print "- Restore sepolicy patch" - backup_restore "$MIRRORDIR$sepol" && rm -rf "$MIRRORDIR$sepol".gz -fi - - -ui_print "- Removing Magisk binaries" -rm -rf $MIRRORDIR/system/etc/init/*magisk* $MIRRORDIR/system/system/etc/init/*magisk* $MIRRORDIR/system_root/system/etc/init/*magisk* \ -$MIRRORDIR/system/xbin/magisk $MIRRORDIR/system/xbin/.magisk || abort "! Cannot uninstall" - -backup_restore "$MIRRORDIR/system/etc/init/bootanim.rc" && rm -rf "$MIRRORDIR/system/etc/init/bootanim.rc.gz" +TRANSACTION=$COMMONDIR/system_mode_transaction.sh +SM_UNINSTALL_BB=$INSTALLER/lib/$ABI/libbusybox.so +[ -f "$TRANSACTION" ] || abort "! System Mode transaction support is missing" +[ -f "$SM_UNINSTALL_BB" ] || abort "! System Mode transaction runtime is missing" +chmod 755 "$SM_UNINSTALL_BB" || abort "! Cannot prepare System Mode transaction runtime" +. "$TRANSACTION" || abort "! Cannot load System Mode transaction support" +sm_configure "$COMMONDIR" "$MIRRORDIR" /system/etc/init/magisk "$SM_UNINSTALL_BB" || \ + abort "! Cannot initialize System Mode transaction support" +ui_print "- Restoring exact manifest-owned System Mode paths" +sm_uninstall || abort "! Exact System Mode uninstall refused; use the verified external restore" +SYSTEM_MODE_UNINSTALLED=true else @@ -242,23 +248,28 @@ esac fi -if $BOOTMODE; then - ui_print "- Removing modules" - magisk --remove-modules -n -fi - -ui_print "- Removing Magisk files" -rm -rf \ -/cache/*magisk* /cache/unblock /data/*magisk* /data/cache/*magisk* /data/property/*magisk* \ -/data/Magisk.apk /data/busybox /data/custom_ramdisk_patch.sh /data/adb/*magisk* \ -/data/adb/post-fs-data.d /data/adb/service.d /data/adb/modules* \ -/data/unencrypted/magisk /metadata/magisk /persist/magisk /mnt/vendor/persist/magisk - -ADDOND=/system/addon.d/99-magisk.sh -if [ -f $ADDOND ]; then - blockdev --setrw /dev/block/mapper/system$SLOT 2>/dev/null - mount -o rw,remount /system || mount -o rw,remount / - rm -f $ADDOND +if $SYSTEM_MODE_UNINSTALL; then + ui_print "- Preserving every path outside the System Mode ownership manifest" +else + if $BOOTMODE; then + ui_print "- Removing modules" + magisk --remove-modules -n + fi + + ui_print "- Removing Magisk files" + rm -rf \ + /cache/magisk /cache/magisk.log /cache/magisk.apk /cache/unblock \ + /data/magisk /data/magisk.img /data/magisk_merge.img /data/cache/magisk /data/property/magisk \ + /data/Magisk.apk /data/busybox /data/custom_ramdisk_patch.sh /data/adb/magisk /data/adb/magisk.db \ + /data/adb/modules /data/adb/modules_update \ + /data/unencrypted/magisk /metadata/magisk /persist/magisk /mnt/vendor/persist/magisk + + ADDOND=/system/addon.d/99-magisk.sh + if [ -f $ADDOND ]; then + blockdev --setrw /dev/block/mapper/system$SLOT 2>/dev/null + mount -o rw,remount /system || mount -o rw,remount / + rm -f $ADDOND + fi fi cd / @@ -268,7 +279,17 @@ if $BOOTMODE; then ui_print " The Magisk app will uninstall itself, and" ui_print " the device will reboot after a few seconds" ui_print "********************************************" - (sleep 8; /system/bin/reboot)& + case "$4" in + ""|*[!A-Za-z0-9._]*|.*|*.|*..*) + (sleep 8; /system/bin/reboot)& + ;; + *.*) + (sleep 4; pm uninstall "$4" >/dev/null 2>&1; sleep 4; /system/bin/reboot)& + ;; + *) + (sleep 8; /system/bin/reboot)& + ;; + esac else ui_print "********************************************" ui_print " The Magisk app will not be uninstalled" diff --git a/tests/security_lab/test_device_corpus.py b/tests/security_lab/test_device_corpus.py index 89cf4969c..273a29934 100644 --- a/tests/security_lab/test_device_corpus.py +++ b/tests/security_lab/test_device_corpus.py @@ -171,6 +171,18 @@ def test_normal_avd_readiness_normalizes_legacy_adb_crlf(self) -> None: self.assertEqual(2, readiness.count(normalize)) self.assertLess(readiness.rindex(normalize), readiness.index(match)) + def test_normal_avd_waits_for_a_new_owned_boot(self) -> None: + source = (ROOT / "scripts" / "avd_test.sh").read_text(encoding="utf-8") + start = source.index("wait_emu()") + end = source.index("wait_test_ready()", start) + readiness = source[start:end] + self.assertIn("wait_emu_transport_gone", source) + self.assertIn("/proc/sys/kernel/random/boot_id", readiness) + self.assertIn("getprop ro.boot.qemu.avd_name", readiness) + self.assertIn("getprop ro.kernel.qemu.avd_name", readiness) + self.assertIn('[ "$active_avd" = "$avd_name" ]', readiness) + self.assertIn('[ "$boot_id" != "$emu_boot_id" ]', readiness) + if __name__ == "__main__": unittest.main() diff --git a/tests/system_mode/test_authorization.py b/tests/system_mode/test_authorization.py new file mode 100644 index 000000000..69ae8d2a5 --- /dev/null +++ b/tests/system_mode/test_authorization.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path +import unittest + +from tools.system_mode.authorization import build_authorization +from tools.system_mode.doctor import classify_report, fixture_report + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "tools" / "system_mode" / "fixtures" / "mumu-writable.json" + + +def parse_authorization(raw: bytes) -> dict[str, str]: + return dict(line.split("=", 1) for line in raw.decode("ascii").splitlines()) + + +class SystemModeAuthorizationTest(unittest.TestCase): + def supported_report(self) -> dict[str, object]: + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + return fixture_report(fixture["input"]) + + def test_authorization_binds_recovery_to_the_exact_target(self) -> None: + report = self.supported_report() + raw_report = json.dumps(report, sort_keys=True).encode("utf-8") + authorization = parse_authorization(build_authorization(report, raw_report)) + + self.assertEqual("1", authorization["SCHEMA_VERSION"]) + self.assertEqual( + report["device"]["fingerprint_sha256"], + authorization["FINGERPRINT_SHA256"], + ) + self.assertEqual("0" * 64, authorization["BACKUP_SHA256"]) + self.assertEqual( + report["recovery"]["backup_location"], + base64.b64decode(authorization["BACKUP_LOCATION_B64"]).decode("utf-8"), + ) + self.assertEqual( + report["recovery"]["restore_command"], + base64.b64decode(authorization["RESTORE_COMMAND_B64"]).decode("utf-8"), + ) + self.assertEqual( + report["init"]["selected_directory"], + base64.b64decode(authorization["INIT_DIRECTORY_B64"]).decode("utf-8"), + ) + + def test_authorization_rejects_unproven_recovery(self) -> None: + report = self.supported_report() + report["recovery"]["verified"] = False + report["assessment"] = classify_report(report) + with self.assertRaisesRegex(ValueError, "verified external recovery"): + build_authorization(report, b"{}") + + def test_authorization_rejects_a_non_supported_report(self) -> None: + report = self.supported_report() + report["layout"]["system_fs_type"] = "erofs" + report["assessment"] = classify_report(report) + with self.assertRaisesRegex(ValueError, "not supported"): + build_authorization(report, b"{}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system_mode/test_doctor.py b/tests/system_mode/test_doctor.py index dec9ca939..918ea9172 100644 --- a/tests/system_mode/test_doctor.py +++ b/tests/system_mode/test_doctor.py @@ -47,6 +47,7 @@ def test_validation_rejects_unknown_reason_code(self) -> None: def test_evidence_requires_complete_recovery_tuple(self) -> None: evidence = QualificationEvidence( snapshot_id="snapshot-1", + backup_location="external-backup-1", backup_digest="a" * 64, recovery_verified=True, ) @@ -57,6 +58,7 @@ def test_evidence_requires_complete_recovery_tuple(self) -> None: ) report["recovery"] = { "snapshot_id": "snapshot-1", + "backup_location": "external-backup-1", "backup_digest": None, "restore_command": "restore snapshot-1", "verified": True, diff --git a/tests/system_mode/test_installer_safety.py b/tests/system_mode/test_installer_safety.py index e8a6d8941..2696c42d4 100644 --- a/tests/system_mode/test_installer_safety.py +++ b/tests/system_mode/test_installer_safety.py @@ -11,6 +11,9 @@ MANAGER = ROOT / "app" / "src" / "main" / "res" / "raw" / "manager.sh" FLASH_SCRIPT = ROOT / "scripts" / "flash_script.sh" ADDON_SCRIPT = ROOT / "scripts" / "addon.d.sh" +TRANSACTION_SCRIPT = ROOT / "scripts" / "system_mode_transaction.sh" +VERIFY_SCRIPT = ROOT / "scripts" / "system_mode_verify.sh" +UNINSTALLER_SCRIPT = ROOT / "scripts" / "uninstaller.sh" INSTALLER = ( ROOT / "app" @@ -65,6 +68,9 @@ def setUpClass(cls) -> None: cls.source = MANAGER.read_text(encoding="utf-8") cls.flash_script = FLASH_SCRIPT.read_text(encoding="utf-8") cls.addon_script = ADDON_SCRIPT.read_text(encoding="utf-8") + cls.transaction_script = TRANSACTION_SCRIPT.read_text(encoding="utf-8") + cls.verify_script = VERIFY_SCRIPT.read_text(encoding="utf-8") + cls.uninstaller_script = UNINSTALLER_SCRIPT.read_text(encoding="utf-8") cls.installer = INSTALLER.read_text(encoding="utf-8") cls.install_view_model = INSTALL_VIEW_MODEL.read_text(encoding="utf-8") cls.system_mode_dialog = SYSTEM_MODE_DIALOG.read_text(encoding="utf-8") @@ -102,14 +108,9 @@ def test_policy_and_init_rollback_preserve_exact_preupgrade_bytes(self) -> None: direct.index('stage_file_rollback "$MIRRORDIR$sepol"'), direct.index('backup_restore "$MIRRORDIR$sepol"'), ) - self.assertLess( - direct.index( - 'stage_file_rollback "$MIRRORDIR/system/etc/init/bootanim.rc"' - ), - direct.index( - 'backup_restore "$MIRRORDIR/system/etc/init/bootanim.rc"' - ), - ) + self.assertIn("sm_restore_legacy_bootanim", direct) + self.assertIn('hijackrc="$(sm_real_path "$SM_INIT_PATH")"', direct) + self.assertNotIn('echo "$(magiskrc "$MAGISKTMP_TO_INSTALL")" >>', direct) self.assertIn('restore_staged_file "$mirror$SYSTEM_INSTALL_SEPOL"', rollback) self.assertIn('"$SYSTEM_INSTALL_SEPOL_HAD_GZ"', rollback) self.assertIn('"$SYSTEM_INSTALL_BOOTANIM_HAD_GZ"', rollback) @@ -273,8 +274,8 @@ def test_recovery_entry_points_reject_release_before_mutation(self) -> None: self.assertNotIn('"$MODE_BINARY" -v', self.flash_script) flash_gate = self.flash_script.index(":MAGISK:D ") self.assertLess(flash_gate, self.flash_script.index("remove_system_su")) - self.assertLess(flash_gate, self.flash_script.index("rm -rf $MAGISKBIN/*")) - self.assertLess(flash_gate, self.flash_script.index("direct_install_system")) + self.assertLess(flash_gate, self.flash_script.index('rm -rf "$INSTALL_ENV"/*')) + self.assertLess(flash_gate, self.flash_script.index("xdirect_install_system")) addon_main = self.addon_script[ self.addon_script.index("main()") : self.addon_script.index('\ncase "$1" in') @@ -294,6 +295,199 @@ def test_recovery_system_mode_does_not_require_a_boot_image(self) -> None: self.assertLess(guard, find) self.assertLess(find, end) + def test_recovery_mode_requires_explicit_configuration(self) -> None: + self.assertNotIn('grep -q "systemmagisk"', self.flash_script) + self.assertIn('getvar SYSTEMMODE', self.flash_script) + self.assertIn('[ "$SYSTEMINSTALL" != "true" ]', self.flash_script) + + def test_recovery_system_mode_uses_the_complete_app_transaction(self) -> None: + system_branch = self.flash_script[ + self.flash_script.index('if [ "$SYSTEMINSTALL" == "true" ]') : + self.flash_script.index("# addon.d") + ] + self.assertIn('xdirect_install_system "$MAGISKBINTMP" "$APK"', system_branch) + self.assertNotIn('\n direct_install_system "', system_branch) + self.assertNotIn("sed -i", system_branch) + environment = self.flash_script[ + self.flash_script.index( + "# Build System Mode only in the installer staging directory." + ) : + self.flash_script.index("# Image Patching") + ] + self.assertIn('INSTALL_ENV=$MAGISKBINTMP', environment) + self.assertIn('if [ "$SYSTEMINSTALL" != "true" ]', environment) + + def test_system_mode_does_not_remove_unowned_legacy_system_su(self) -> None: + for source in (self.flash_script, self.addon_script): + with self.subTest(source="flash" if source is self.flash_script else "addon"): + remove = source.index("remove_system_su") + guard = source.rfind('if [ "$SYSTEMINSTALL" != "true" ]', 0, remove) + self.assertGreaterEqual(guard, 0) + + def test_persistent_transaction_records_every_release_state(self) -> None: + transaction = self.transaction_script + for state in ( + "UNINSTALLED", + "PREFLIGHTED", + "STAGED", + "COMMITTED", + "BOOT_VERIFIED", + "ROLLBACK_REQUIRED", + "ROLLING_BACK", + "FAILED", + ): + self.assertIn(state, transaction) + self.assertIn("sm_atomic_publish", transaction) + self.assertIn('"$SM_BB" fsync', transaction) + self.assertIn("process-death:$boundary", transaction) + self.assertIn("reboot:$boundary", transaction) + + def test_commit_removes_staging_before_exposing_committed_state(self) -> None: + commit = function_body(self.transaction_script, "sm_commit_transaction") + manifest_copy = commit.index("manifest-copy-published") + cleanup = commit.index("sm_cleanup_staging", manifest_copy) + committed = commit.index("sm_update_state COMMITTED", cleanup) + self.assertLess(manifest_copy, cleanup) + self.assertLess(cleanup, committed) + + def test_transaction_is_bound_to_external_recovery_and_live_fingerprint(self) -> None: + validate = function_body(self.transaction_script, "sm_validate_authorization") + live = function_body(self.transaction_script, "sm_validate_live_target") + self.assertIn("BACKUP_SHA256", validate) + self.assertIn("RESTORE_COMMAND_B64", validate) + self.assertIn("sm_validate_live_target", validate) + self.assertIn("ro.build.fingerprint", live) + self.assertIn("ro.product.cpu.abilist", self.transaction_script) + self.assertIn("different target", live) + + def test_context_ownership_is_strict_only_when_selinux_enforces(self) -> None: + context = function_body(self.transaction_script, "sm_context") + self.assertIn("/sys/fs/selinux/enforce", context) + self.assertIn('!= 1', context) + self.assertIn("printf '%s\\n' -", context) + + def test_runtime_and_init_selection_are_layout_aware(self) -> None: + select = function_body(self.transaction_script, "sm_select_strategies") + self.assertIn("/debug_ramdisk", select) + self.assertIn("/sbin", select) + self.assertIn('SM_INIT_PATH="$SM_AUTH_INIT_DIRECTORY/magisk.rc"', select) + self.assertIn('sm_probe_writable_directory "$real"', select) + self.assertNotIn('[ -w "$real" ]', select) + direct = function_body(self.source, "direct_install_system") + self.assertIn('MAGISKTMP_TO_INSTALL="$SM_RUNTIME_PATH"', direct) + self.assertIn('hijackrc="$(sm_real_path "$SM_INIT_PATH")"', direct) + self.assertIn('sm_probe_writable_directory "$runtime_dir"', direct) + + def test_boot_verifier_recovers_immediately_with_a_real_busybox_name(self) -> None: + verify = self.verify_script + failed_verify = verify.index("if sm_verify_boot") + reload_state = verify.index("sm_load_transaction", failed_verify) + recover = verify.index("sm_recover_pending", reload_state) + self.assertLess(failed_verify, reload_state) + self.assertLess(reload_state, recover) + self.assertIn('RECOVERY_BB="$RECOVERY_DIR/busybox"', verify) + self.assertNotIn(".kitsune-system-mode-busybox", verify) + stop = verify.index('"$SM_RUNTIME_PATH/magisk" --stop') + remount = verify.index("sm_prepare_persistent_mounts", stop) + self.assertLess(stop, recover) + self.assertLess(stop, remount) + self.assertLess(remount, recover) + self.assertIn("sm_restore_persistent_mounts", verify) + self.assertIn('"$RECOVERY_BB" reboot -f', verify) + relocate = verify.index('KITSUNE_SYSTEM_MODE_VERIFY_COPY=') + source = verify.index('. "$TRANSACTION"') + self.assertLess(relocate, source) + self.assertIn('exec /system/bin/sh "$KITSUNE_SYSTEM_MODE_VERIFY_COPY"', verify) + self.assertIn("verify_exit", verify) + remount_function = function_body(self.transaction_script, "sm_remount") + self.assertIn("/system/bin/mount", remount_function) + self.assertIn('"$SM_BB" mount', remount_function) + restore_mounts = function_body( + self.transaction_script, "sm_restore_persistent_mounts" + ) + self.assertIn("for mountpoint in $SM_PERSISTENT_REMOUNTED", restore_mounts) + self.assertNotIn("remaining%%|*", restore_mounts) + + def test_uninstall_is_manifest_and_hash_driven_without_wildcard_deletes(self) -> None: + self.assertIn("sm_uninstall", self.uninstaller_script) + self.assertIn("sm_verify_owned", self.transaction_script) + self.assertIn("sm_assert_no_unowned_files", self.transaction_script) + exact_tail_start = self.uninstaller_script.index( + "if $SYSTEM_MODE_UNINSTALL; then", + self.uninstaller_script.index("SYSTEM_MODE_UNINSTALLED=true"), + ) + exact_tail = self.uninstaller_script[ + exact_tail_start : self.uninstaller_script.index("\nelse\n", exact_tail_start) + ] + self.assertIn("Preserving every path outside", exact_tail) + self.assertNotIn("--remove-modules", exact_tail) + self.assertNotIn("/data/adb/modules", exact_tail) + self.assertNotRegex(self.uninstaller_script, r"rm\s+-rf[^\n]*\*magisk\*") + self.assertNotIn("/data/adb/*magisk*", self.uninstaller_script) + self.assertNotIn("/data/adb/modules*", self.uninstaller_script) + + def test_upgrade_refuses_to_adopt_modified_or_retargeted_state(self) -> None: + validate = function_body(self.transaction_script, "sm_validate_installed_state") + begin = function_body(self.transaction_script, "sm_begin_transaction") + self.assertIn("sm_verify_owned", validate) + self.assertIn("sm_assert_no_unowned_files", validate) + self.assertIn("sm_validate_originals", validate) + self.assertIn("Refusing to upgrade a modified", begin) + self.assertIn("Refusing to change System Mode adapter", begin) + self.assertGreater( + begin.index('SM_SOURCE_COMMIT="$requested_source_commit"'), + begin.index("sm_recover_pending"), + ) + + def test_interrupted_uninstall_is_discoverable_and_recovered_on_retry(self) -> None: + detection = self.uninstaller_script[ + self.uninstaller_script.index("SYSTEM_MODE_UNINSTALL=false") : + self.uninstaller_script.index("backup_restore()") + ] + uninstall = self.transaction_script[ + self.transaction_script.index("sm_uninstall()") : + ] + self.assertIn("/data/adb/kitsune/system-mode/transaction.env", detection) + self.assertIn("ROLLBACK_REQUIRED", detection) + self.assertIn("ROLLING_BACK", detection) + self.assertIn("sm_recover_pending", uninstall) + self.assertLess(uninstall.index("sm_recover_pending"), uninstall.index("sm_validate_installed_state")) + + def test_uninstaller_removes_the_manager_before_root_is_torn_down(self) -> None: + self.assertIn('pm uninstall "$4"', self.uninstaller_script) + uninstall_class = self.installer[ + self.installer.index("class Uninstall(") : + self.installer.index("class FixEnv", self.installer.index("class Uninstall(")) + ] + self.assertNotIn("pm uninstall", uninstall_class) + + def test_system_mode_uninstall_skips_boot_image_discovery(self) -> None: + detection = self.uninstaller_script[ + self.uninstaller_script.index("get_flags") : + self.uninstaller_script.index("# Detect version and architecture") + ] + mode = detection.index("SYSTEM_MODE_UNINSTALL=true") + boot = detection.index("find_boot_image") + self.assertLess(mode, boot) + self.assertIn("else\n find_boot_image", detection) + + def test_uninstall_receipt_does_not_block_a_clean_reinstall(self) -> None: + uninstall = self.transaction_script[ + self.transaction_script.index("sm_uninstall()") : + ] + self.assertIn('"$SM_ORIGINAL_FILE"', uninstall) + self.assertIn('"$SM_STATE_DIR/original"', uninstall) + + def test_apk_and_script_source_identities_must_match(self) -> None: + direct = self.installer[ + self.installer.index("protected suspend fun direct_system()") : + self.installer.index("protected suspend fun secondSlot()") + ] + self.assertIn("BuildConfig.SOURCE_COMMIT", direct) + self.assertIn("BuildConfig.UPSTREAM_BASE", direct) + self.assertIn("KITSUNE_SOURCE_COMMIT", direct) + self.assertIn("KITSUNE_UPSTREAM_BASE", direct) + def test_recovery_never_sources_a_stale_or_missing_manager_script(self) -> None: for script in (self.flash_script, self.addon_script): with self.subTest(script="flash" if script is self.flash_script else "addon"): @@ -317,7 +511,8 @@ def test_system_addon_uses_the_restored_system_payload(self) -> None: def test_successful_non_deferred_recovery_install_commits_transaction(self) -> None: direct = function_body(self.source, "direct_install_system") self.assertIn('if [ "$defer_cleanup" != true ]', direct) - self.assertIn("commit_system_installation || return 1", direct) + self.assertIn("commit_system_installation || { sm_abort_transaction; return 1; }", direct) + self.assertIn("sm_commit_transaction || { sm_abort_transaction; return 1; }", direct) self.assertNotIn('if $BOOTMODE && [ "$defer_cleanup" != true ]', direct) def test_new_runtime_directory_is_owned_by_the_transaction(self) -> None: @@ -332,24 +527,46 @@ def test_new_runtime_directory_is_owned_by_the_transaction(self) -> None: def test_addond_replacement_keeps_rollback_copies(self) -> None: addond = function_body(self.source, "install_addond") preserve = addond.index('mv "$addond/99-magisk.sh" "$script_backup"') - publish = addond.index('cp "$installDir/addon.d.sh" "$addond/99-magisk.sh"') + stage = addond.index('cp "$installDir/addon.d.sh" "$addon_stage"') + publish = addond.index( + 'sm_atomic_publish "$addon_stage" "$addond/99-magisk.sh"' + ) restore = addond.index('mv "$script_backup" "$addond/99-magisk.sh"') - self.assertLess(preserve, publish) + self.assertLess(preserve, stage) + self.assertLess(stage, publish) self.assertGreater(restore, publish) xdirect = function_body(self.source, "xdirect_install_system") self.assertIn('install_addond "$2" "true" "true"', xdirect) self.assertNotIn("run_migrations", xdirect) + def test_app_transaction_applet_survives_staging_cleanup(self) -> None: + xdirect = function_body(self.source, "xdirect_install_system") + pin = xdirect.index('cp -f "$1/busybox" "$transaction_bb"') + install = xdirect.index('direct_install_system "$1" true "$2"') + select = xdirect.index('SM_BB="$transaction_bb"', install) + cleanup = xdirect.index('fix_env "$1" true', select) + commit = xdirect.index("sm_commit_transaction", cleanup) + release = xdirect.rindex('"$transaction_bb" rm -rf "$transaction_dir"') + self.assertIn('/dev/.kitsune-system-mode.$$', xdirect) + self.assertIn('transaction_bb="$transaction_dir/busybox"', xdirect) + self.assertLess(pin, install) + self.assertLess(install, select) + self.assertLess(select, cleanup) + self.assertLess(cleanup, commit) + self.assertGreater(release, commit) + self.assertIn("command -v sm_abort_transaction", xdirect) + normalize = xdirect.index('"$runtime_magisk" --restorecon', cleanup) + self.assertLess(normalize, commit) + def run_manager_harness( self, harness: str, *, expose_addond_path: bool = False, - expose_system_path: bool = False, ) -> subprocess.CompletedProcess[str]: source = self.source - if expose_addond_path or expose_system_path: + if expose_addond_path: start = source.index("install_addond(){") end = source.index("\n}\n\ndirect_install()", start) + 3 function = source[start:end] @@ -357,12 +574,7 @@ def run_manager_harness( declaration = " local addond=/system/addon.d" self.assertEqual(1, function.count(declaration)) function = function.replace(declaration, ' local addond="$4"') - if expose_system_path: - self.assertIn("/system/etc/init/magisk", function) - function = function.replace( - "/system/etc/init/magisk", "${SYSTEM_TEST_DIR}" - ) - if expose_addond_path or expose_system_path: + if expose_addond_path: source = source[:start] + function + source[end:] with tempfile.TemporaryDirectory(prefix="kitsune-installer-fault-") as temp: return subprocess.run( @@ -455,14 +667,18 @@ def test_addond_replaces_dangling_script_symlink_without_following_it(self) -> N mkblknode() { return 0; } blockdev() { return 0; } mount() { return 0; } + sm_fsync() { return 0; } + sm_failpoint() { return 0; } + sm_atomic_publish() { command mv -f "$1" "$2"; } + sm_sha256_file() { shasum -a 256 "$1" | awk '{ print $1 }'; } root="$1" addond="$root/addon.d" MAGISKBIN="$root/runtime" - SYSTEM_TEST_DIR="$root/system/etc/init/magisk" - mkdir -p "$addond" "$MAGISKBIN" "$SYSTEM_TEST_DIR" + mkdir -p "$addond" "$MAGISKBIN" printf payload > "$MAGISKBIN/payload" printf 'SYSTEMINSTALL=false\n' > "$MAGISKBIN/addon.d.sh" printf apk > "$root/app.apk" + SM_ARTIFACT_SHA256="$(sm_sha256_file "$root/app.apk")" ln -s "$root/escaped" "$addond/99-magisk.sh" install_addond "$root/app.apk" true true "$addond" @@ -472,7 +688,6 @@ def test_addond_replaces_dangling_script_symlink_without_following_it(self) -> N test ! -e "$addond/.99-magisk.sh.kitsune-old" """, expose_addond_path=True, - expose_system_path=True, ) self.assertEqual(0, result.returncode, result.stderr) diff --git a/tests/system_mode/test_transaction.py b/tests/system_mode/test_transaction.py new file mode 100644 index 000000000..0d09ec771 --- /dev/null +++ b/tests/system_mode/test_transaction.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +TRANSACTION = ROOT / "scripts" / "system_mode_transaction.sh" + + +SHELL_PREAMBLE = r""" +set -eu +. "$1" +TEST_ROOT="$2" + +bb() { + applet="$1" + shift + case "$applet" in + sha256sum) shasum -a 256 "$@" ;; + stat) + if [ "$1" = -c ]; then + format="$2" + path="$3" + case "$format" in + %s) /usr/bin/stat -f %z "$path" ;; + %a) /usr/bin/stat -f %Lp "$path" ;; + %u) /usr/bin/stat -f %u "$path" ;; + %g) /usr/bin/stat -f %g "$path" ;; + *) return 2 ;; + esac + else + command stat "$@" + fi + ;; + *) command "$applet" "$@" ;; + esac +} + +SM_BB=bb +sm_fsync() { return 0; } +sm_fsync_tree() { return 0; } +sm_log() { :; } +""" + + +def run_harness(body: str, root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", SHELL_PREAMBLE + textwrap.dedent(body), "transaction", str(TRANSACTION), str(root)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +class SystemModeTransactionTest(unittest.TestCase): + def test_receipt_rejects_redirected_paths_and_a_different_live_target(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-receipt-") as temp: + result = run_harness( + r""" + SM_STATE_DIR="$TEST_ROOT/state" + SM_TRANSACTION_FILE="$SM_STATE_DIR/transaction.env" + SM_SYSTEM_DIR=/system/etc/init/magisk + transaction_id=12345678-1234-1234-1234-123456789abc + install_id=abcdefab-cdef-abcd-efab-cdefabcdefab + LIVE_ABIS=arm64-v8a + getprop() { + case "$1" in + ro.build.fingerprint) printf '%s\n' exact/fingerprint ;; + ro.build.version.sdk) printf '%s\n' 32 ;; + ro.product.cpu.abilist) printf '%s\n' "$LIVE_ABIS" ;; + ro.product.cpu.abi) printf '%s\n' arm64-v8a ;; + esac + } + b64() { printf '%s' "$1" | base64 | tr -d '\n'; } + fingerprint="$(printf '%s' exact/fingerprint | shasum -a 256 | awk '{ print $1 }')" + mkdir -p "$SM_STATE_DIR" + { + printf 'SCHEMA_VERSION=1\n' + printf 'INSTALL_ID=%s\n' "$install_id" + printf 'TRANSACTION_ID=%s\n' "$transaction_id" + printf 'STATE=BOOT_VERIFIED\n' + printf 'PRIOR_STATE=UNINSTALLED\n' + printf 'FINGERPRINT_SHA256=%s\n' "$fingerprint" + printf 'TARGET_API=32\n' + printf 'REPORT_SHA256=%064d\n' 0 + printf 'BACKUP_SHA256=%064d\n' 0 + printf 'ADAPTER_ID_B64=%s\n' "$(b64 mumu-1.4.46)" + printf 'TARGET_ABIS_B64=%s\n' "$(b64 arm64-v8a)" + printf 'SNAPSHOT_ID_B64=%s\n' "$(b64 snapshot)" + printf 'BACKUP_LOCATION_B64=%s\n' "$(b64 external-backup)" + printf 'RESTORE_COMMAND_B64=%s\n' "$(b64 'restore exact backup')" + printf 'INIT_PATH=/system/etc/init/magisk.rc\n' + printf 'POLICY_PATH=\n' + printf 'POLICY_SOURCE=\n' + printf 'RUNTIME_PATH=/sbin\n' + printf 'SELINUX_STRATEGY=disabled\n' + printf 'SOURCE_COMMIT=%040d\n' 0 + printf 'UPSTREAM_BASE=%040d\n' 0 + printf 'ARTIFACT_SHA256=%064d\n' 0 + printf 'PRODUCT_VERSION_B64=%s\n' "$(b64 test-version)" + printf 'COMMIT_BOOT_ID=11111111-1111-1111-1111-111111111111\n' + printf 'ROLLBACK_DIR=%s/rollback/%s\n' "$SM_STATE_DIR" "$transaction_id" + printf 'STAGING_PATH=/system/etc/init/.magisk.kitsune-stage-%s\n' "$transaction_id" + } >"$SM_TRANSACTION_FILE" + + sm_load_transaction + + sed 's#ROLLBACK_DIR=.*#ROLLBACK_DIR=/data/adb/kitsune/system-mode/rollback/../escaped#' \ + "$SM_TRANSACTION_FILE" >"$SM_TRANSACTION_FILE.bad" + mv "$SM_TRANSACTION_FILE.bad" "$SM_TRANSACTION_FILE" + ! sm_load_transaction + + sed "s#ROLLBACK_DIR=.*#ROLLBACK_DIR=$SM_STATE_DIR/rollback/$transaction_id#" \ + "$SM_TRANSACTION_FILE" >"$SM_TRANSACTION_FILE.good" + mv "$SM_TRANSACTION_FILE.good" "$SM_TRANSACTION_FILE" + LIVE_ABIS=x86_64 + ! sm_load_transaction + """, + Path(temp), + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_atomic_publish_fault_matrix_preserves_or_reports_the_boundary(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-atomic-") as temp: + result = run_harness( + r""" + stage="$TEST_ROOT/stage" + destination="$TEST_ROOT/destination" + for fault in enospc erofs short-write fsync-file rename; do + printf new >"$stage" + printf old >"$destination" + KITSUNE_SYSTEM_MODE_FAIL_AT="$fault:atomic-test" + export KITSUNE_SYSTEM_MODE_FAIL_AT + ! sm_atomic_publish "$stage" "$destination" atomic-test + test "$(cat "$destination")" = old + done + + printf new >"$stage" + printf old >"$destination" + KITSUNE_SYSTEM_MODE_FAIL_AT=fsync-parent:atomic-test + export KITSUNE_SYSTEM_MODE_FAIL_AT + ! sm_atomic_publish "$stage" "$destination" atomic-test + test "$(cat "$destination")" = new + + printf final >"$stage" + unset KITSUNE_SYSTEM_MODE_FAIL_AT + sm_atomic_publish "$stage" "$destination" atomic-test + test "$(cat "$destination")" = final + test ! -e "$stage" + """, + Path(temp), + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_process_death_occurs_only_after_the_publish_is_visible(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-death-") as temp: + root = Path(temp) + result = run_harness( + r""" + printf committed >"$TEST_ROOT/stage" + KITSUNE_SYSTEM_MODE_FAIL_AT=process-death:atomic-test + export KITSUNE_SYSTEM_MODE_FAIL_AT + sm_atomic_publish "$TEST_ROOT/stage" "$TEST_ROOT/destination" atomic-test + """, + root, + ) + self.assertNotEqual(0, result.returncode) + self.assertEqual("committed", (root / "destination").read_text(encoding="utf-8")) + + def test_state_metadata_snapshot_restores_an_upgrade_exactly(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-state-") as temp: + result = run_harness( + r""" + SM_STATE_DIR="$TEST_ROOT/state" + SM_TRANSACTION_FILE="$SM_STATE_DIR/transaction.env" + SM_MANIFEST_COPY="$SM_STATE_DIR/install-manifest.json" + SM_OWNERSHIP_FILE="$SM_STATE_DIR/ownership.tsv" + SM_ORIGINAL_FILE="$SM_STATE_DIR/originals.tsv" + SM_JOURNAL_FILE="$SM_STATE_DIR/journal.tsv" + SM_BOOT_PROOF="$SM_STATE_DIR/boot-verified.env" + SM_ROLLBACK_DIR="$SM_STATE_DIR/rollback/new" + mkdir -p "$SM_STATE_DIR/original" + printf prior-transaction >"$SM_TRANSACTION_FILE" + printf prior-manifest >"$SM_MANIFEST_COPY" + printf prior-ownership >"$SM_OWNERSHIP_FILE" + printf prior-originals >"$SM_ORIGINAL_FILE" + printf prior-journal >"$SM_JOURNAL_FILE" + printf prior-proof >"$SM_BOOT_PROOF" + printf prior-backup >"$SM_STATE_DIR/original/data" + + sm_snapshot_state_metadata + printf new-transaction >"$SM_TRANSACTION_FILE" + printf new-manifest >"$SM_MANIFEST_COPY" + rm -f "$SM_OWNERSHIP_FILE" "$SM_BOOT_PROOF" + printf new-backup >"$SM_STATE_DIR/original/data" + sm_restore_state_metadata + + test "$(cat "$SM_TRANSACTION_FILE")" = prior-transaction + test "$(cat "$SM_MANIFEST_COPY")" = prior-manifest + test "$(cat "$SM_OWNERSHIP_FILE")" = prior-ownership + test "$(cat "$SM_ORIGINAL_FILE")" = prior-originals + test "$(cat "$SM_JOURNAL_FILE")" = prior-journal + test "$(cat "$SM_BOOT_PROOF")" = prior-proof + test "$(cat "$SM_STATE_DIR/original/data")" = prior-backup + """, + Path(temp), + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_reverse_snapshot_restores_every_persistent_root_and_receipt(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-rollback-") as temp: + result = run_harness( + r""" + SM_STATE_DIR="$TEST_ROOT/state" + SM_TRANSACTION_FILE="$SM_STATE_DIR/transaction.env" + SM_MANIFEST_COPY="$SM_STATE_DIR/install-manifest.json" + SM_OWNERSHIP_FILE="$SM_STATE_DIR/ownership.tsv" + SM_ORIGINAL_FILE="$SM_STATE_DIR/originals.tsv" + SM_JOURNAL_FILE="$SM_STATE_DIR/journal.tsv" + SM_BOOT_PROOF="$SM_STATE_DIR/boot-verified.env" + SM_SYSTEM_DIR=/system/etc/init/magisk + SM_INIT_PATH=/system/etc/init/magisk.rc + SM_POLICY_PATH= + SM_STAGING_PATH=/system/etc/init/.magisk.kitsune-stage-test + SM_ROLLBACK_DIR="$SM_STATE_DIR/rollback/new" + SM_PRIOR_STATE=BOOT_VERIFIED + sm_real_path() { printf '%s%s\n' "$TEST_ROOT/root" "$1"; } + sm_update_state() { SM_STATE="$1"; } + + mkdir -p "$SM_STATE_DIR/original" "$TEST_ROOT/root/system/etc/init/magisk" \ + "$TEST_ROOT/root/data/adb/magisk" "$TEST_ROOT/root/system/addon.d/magisk" + printf prior-transaction >"$SM_TRANSACTION_FILE" + printf prior-manifest >"$SM_MANIFEST_COPY" + printf prior-ownership >"$SM_OWNERSHIP_FILE" + printf prior-originals >"$SM_ORIGINAL_FILE" + printf prior-journal >"$SM_JOURNAL_FILE" + printf prior-proof >"$SM_BOOT_PROOF" + printf prior-backup >"$SM_STATE_DIR/original/data" + printf payload-old >"$TEST_ROOT/root/system/etc/init/magisk/file" + printf legacy-old >"$TEST_ROOT/root/system/etc/init/magisk.rc" + printf bootanim-old >"$TEST_ROOT/root/system/etc/init/bootanim.rc" + printf bootanim-gz-old >"$TEST_ROOT/root/system/etc/init/bootanim.rc.gz" + printf runtime-old >"$TEST_ROOT/root/data/adb/magisk/file" + printf addon-old >"$TEST_ROOT/root/system/addon.d/99-magisk.sh" + printf addon-dir-old >"$TEST_ROOT/root/system/addon.d/magisk/file" + + sm_snapshot_state_metadata + sm_snapshot_all + printf new-transaction >"$SM_TRANSACTION_FILE" + printf payload-new >"$TEST_ROOT/root/system/etc/init/magisk/file" + printf legacy-new >"$TEST_ROOT/root/system/etc/init/magisk.rc" + printf bootanim-new >"$TEST_ROOT/root/system/etc/init/bootanim.rc" + printf runtime-new >"$TEST_ROOT/root/data/adb/magisk/file" + printf addon-new >"$TEST_ROOT/root/system/addon.d/99-magisk.sh" + printf addon-dir-new >"$TEST_ROOT/root/system/addon.d/magisk/file" + + sm_restore_snapshot + test "$(cat "$TEST_ROOT/root/system/etc/init/magisk/file")" = payload-old + test "$(cat "$TEST_ROOT/root/system/etc/init/magisk.rc")" = legacy-old + test "$(cat "$TEST_ROOT/root/system/etc/init/bootanim.rc")" = bootanim-old + test "$(cat "$TEST_ROOT/root/system/etc/init/bootanim.rc.gz")" = bootanim-gz-old + test "$(cat "$TEST_ROOT/root/data/adb/magisk/file")" = runtime-old + test "$(cat "$TEST_ROOT/root/system/addon.d/99-magisk.sh")" = addon-old + test "$(cat "$TEST_ROOT/root/system/addon.d/magisk/file")" = addon-dir-old + test "$(cat "$SM_TRANSACTION_FILE")" = prior-transaction + test ! -e "$SM_ROLLBACK_DIR" + """, + Path(temp), + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_reverse_snapshot_accepts_an_optional_tree_with_no_parent(self) -> None: + with tempfile.TemporaryDirectory(prefix="kitsune-transaction-absent-") as temp: + result = run_harness( + r""" + SM_STATE_DIR="$TEST_ROOT/state" + SM_TRANSACTION_FILE="$SM_STATE_DIR/transaction.env" + SM_MANIFEST_COPY="$SM_STATE_DIR/install-manifest.json" + SM_OWNERSHIP_FILE="$SM_STATE_DIR/ownership.tsv" + SM_ORIGINAL_FILE="$SM_STATE_DIR/originals.tsv" + SM_JOURNAL_FILE="$SM_STATE_DIR/journal.tsv" + SM_BOOT_PROOF="$SM_STATE_DIR/boot-verified.env" + SM_SYSTEM_DIR=/system/etc/init/magisk + SM_INIT_PATH=/system/etc/init/magisk.rc + SM_POLICY_PATH= + SM_STAGING_PATH=/system/etc/init/.magisk.kitsune-stage-test + SM_ROLLBACK_DIR="$SM_STATE_DIR/rollback/new" + sm_real_path() { printf '%s%s\n' "$TEST_ROOT/root" "$1"; } + sm_update_state() { SM_STATE="$1"; } + + mkdir -p "$SM_STATE_DIR/original" \ + "$TEST_ROOT/root/system/etc/init/magisk" \ + "$TEST_ROOT/root/data/adb/magisk" + printf prior-transaction >"$SM_TRANSACTION_FILE" + printf payload-old >"$TEST_ROOT/root/system/etc/init/magisk/file" + printf init-old >"$TEST_ROOT/root/system/etc/init/magisk.rc" + printf runtime-old >"$TEST_ROOT/root/data/adb/magisk/file" + sm_snapshot_state_metadata + sm_snapshot_all + + # Model BusyBox fsync: unlike the normal harness stub, an + # absent path is an error. /system/addon.d never existed. + sm_fsync() { + local path + for path in "$@"; do [ -e "$path" ] || return 1; done + } + sm_restore_snapshot + test ! -e "$TEST_ROOT/root/system/addon.d" + test "$(cat "$TEST_ROOT/root/system/etc/init/magisk/file")" = payload-old + test "$(cat "$TEST_ROOT/root/data/adb/magisk/file")" = runtime-old + """, + Path(temp), + ) + self.assertEqual(0, result.returncode, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/system_mode/authorization.py b/tools/system_mode/authorization.py new file mode 100644 index 000000000..b1f74a5cb --- /dev/null +++ b/tools/system_mode/authorization.py @@ -0,0 +1,94 @@ +"""Create the explicit host-to-installer recovery authorization contract.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +import re +import shlex +import tempfile +from typing import Any, Mapping + +from tools.system_mode.doctor import AdbClient, ProbeError, validate_report + + +AUTHORIZATION_SCHEMA_VERSION = 1 +AUTHORIZATION_PATH = "/data/local/tmp/kitsune-system-mode-recovery-v1.env" + + +def _b64(value: str) -> str: + return base64.b64encode(value.encode("utf-8")).decode("ascii") + + +def _safe_adapter_component(value: object) -> str: + component = re.sub(r"[^a-z0-9._-]+", "-", str(value or "unknown").lower()) + return component.strip("-") or "unknown" + + +def build_authorization(report: Mapping[str, Any], report_bytes: bytes) -> bytes: + """Return a non-executable, line-oriented authorization for one exact target.""" + + validate_report(report) + recovery = report["recovery"] + persistence = report["persistence"] + init = report["init"] + if not recovery.get("verified"): + raise ValueError("doctor report does not contain verified external recovery") + if not persistence.get("proven"): + raise ValueError("doctor report does not contain persistent-write evidence") + if init.get("import_proof") != "proven" or not init.get("selected_directory"): + raise ValueError("doctor report does not prove an init import directory") + if report["assessment"].get("verdict") != "supported": + raise ValueError("doctor report is not supported") + + device = report["device"] + selinux = report["selinux"] + adapter_id = "-".join( + ( + _safe_adapter_component(device.get("vendor")), + _safe_adapter_component(device.get("emulator_version") or device.get("model")), + ) + ) + fields = ( + ("SCHEMA_VERSION", str(AUTHORIZATION_SCHEMA_VERSION)), + ("REPORT_SHA256", hashlib.sha256(report_bytes).hexdigest()), + ("FINGERPRINT_SHA256", str(device["fingerprint_sha256"])), + ("TARGET_API", str(device["api"])), + ("TARGET_ABIS_B64", _b64(",".join(device["abis"]))), + ("ADAPTER_ID_B64", _b64(adapter_id)), + ("INIT_DIRECTORY_B64", _b64(str(init["selected_directory"]))), + ("SELINUX_STRATEGY_B64", _b64(str(selinux["strategy"]))), + ("SNAPSHOT_ID_B64", _b64(str(recovery["snapshot_id"]))), + ("BACKUP_LOCATION_B64", _b64(str(recovery["backup_location"]))), + ("BACKUP_SHA256", str(recovery["backup_digest"])), + ("RESTORE_COMMAND_B64", _b64(str(recovery["restore_command"]))), + ) + return ("\n".join(f"{key}={value}" for key, value in fields) + "\n").encode("ascii") + + +def load_authorization_report(path: Path) -> tuple[dict[str, Any], bytes]: + raw = path.read_bytes() + report = json.loads(raw.decode("utf-8")) + if not isinstance(report, dict): + raise ValueError("doctor report root must be an object") + return report, raw + + +def stage_authorization(client: AdbClient, authorization: bytes) -> str: + """Stage and byte-verify an authorization in shell-owned temporary storage.""" + + expected = hashlib.sha256(authorization).hexdigest() + with tempfile.NamedTemporaryFile(prefix="kitsune-system-mode-auth-", suffix=".env") as temp: + temp.write(authorization) + temp.flush() + client.push(temp.name, AUTHORIZATION_PATH) + quoted = shlex.quote(AUTHORIZATION_PATH) + result = client.shell(f"chmod 0600 {quoted} && sha256sum {quoted}") + if result.returncode != 0: + raise ProbeError(result.stderr or "could not verify staged recovery authorization") + actual = result.stdout.split()[0].lower() if result.stdout.split() else "" + if actual != expected: + raise ProbeError("staged recovery authorization digest mismatch") + return expected diff --git a/tools/system_mode/doctor.py b/tools/system_mode/doctor.py index 68cee1648..3abaafa88 100644 --- a/tools/system_mode/doctor.py +++ b/tools/system_mode/doctor.py @@ -83,6 +83,7 @@ class QualificationEvidence: init_import_proven: bool = False snapshot_id: str | None = None + backup_location: str | None = None backup_digest: str | None = None restore_command: str | None = None recovery_verified: bool = False @@ -103,6 +104,7 @@ def recovery_is_proven(self) -> bool: return bool( self.recovery_verified and self.snapshot_id + and self.backup_location and self.backup_digest and self.restore_command ) @@ -160,6 +162,11 @@ def wait_for_device(self) -> None: if result.returncode != 0: raise ProbeError(result.stderr or "ADB target did not become ready") + def push(self, source: str, destination: str) -> None: + result = self._command("push", source, destination, timeout=30) + if result.returncode != 0: + raise ProbeError(result.stderr or f"could not push {source} to {destination}") + def shell(self, command: str, *, root: bool = False, timeout: int | None = None) -> CommandResult: if root: command = f"su -c {shlex.quote(command)}" @@ -873,6 +880,7 @@ def collect_report( }, "recovery": { "snapshot_id": evidence.snapshot_id, + "backup_location": evidence.backup_location, "backup_digest": evidence.backup_digest.lower() if evidence.backup_digest else None, "restore_command": evidence.restore_command, "verified": evidence.recovery_is_proven, @@ -1092,7 +1100,7 @@ def validate_report(report: Mapping[str, Any]) -> None: recovery = report["recovery"] if recovery.get("verified") and not all( recovery.get(field) - for field in ("snapshot_id", "backup_digest", "restore_command") + for field in ("snapshot_id", "backup_location", "backup_digest", "restore_command") ): raise ValueError("verified recovery requires a complete recovery tuple") persistence = report["persistence"] @@ -1166,10 +1174,12 @@ def fixture_report(values: Mapping[str, Any]) -> dict[str, Any]: recovery = dict(fixture_values.get("recovery", {})) if recovery.get("verified"): recovery.setdefault("snapshot_id", "synthetic-snapshot") + recovery.setdefault("backup_location", "synthetic-backup") recovery.setdefault("backup_digest", "0" * 64) recovery.setdefault("restore_command", "synthetic-restore") else: recovery.setdefault("snapshot_id", None) + recovery.setdefault("backup_location", None) recovery.setdefault("backup_digest", None) recovery.setdefault("restore_command", None) fixture_values["recovery"] = recovery diff --git a/tools/system_mode/kitsune.py b/tools/system_mode/kitsune.py index 637359c85..f4a6fc61a 100644 --- a/tools/system_mode/kitsune.py +++ b/tools/system_mode/kitsune.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json from pathlib import Path import sys @@ -21,6 +22,11 @@ load_fixture, validate_report, ) +from tools.system_mode.authorization import ( # noqa: E402 + build_authorization, + load_authorization_report, + stage_authorization, +) def _endpoint(value: str) -> str: @@ -35,6 +41,7 @@ def _doctor(args: argparse.Namespace) -> int: evidence = QualificationEvidence( init_import_proven=args.init_import_proven, snapshot_id=args.snapshot_id, + backup_location=args.backup_location, backup_digest=args.backup_digest, restore_command=args.restore_command, recovery_verified=args.recovery_verified, @@ -99,6 +106,28 @@ def _validate_report(args: argparse.Namespace) -> int: return 0 +def _authorize(args: argparse.Namespace) -> int: + try: + report, raw = load_authorization_report(Path(args.report)) + authorization = build_authorization(report, raw) + client = AdbClient(adb=args.adb, serial=args.serial, timeout=args.timeout) + if args.connect: + client.connect(_endpoint(args.connect)) + client.wait_for_device() + fingerprint = client.shell("getprop ro.build.fingerprint") + if fingerprint.returncode != 0 or not fingerprint.stdout: + raise ProbeError("could not read the live target fingerprint") + live_digest = hashlib.sha256(fingerprint.stdout.encode("utf-8")).hexdigest() + if live_digest != report["device"]["fingerprint_sha256"]: + raise ValueError("doctor report fingerprint does not match the live target") + digest = stage_authorization(client, authorization) + except (OSError, ProbeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + print(f"authorization failed: {exc}", file=sys.stderr) + return 2 + print(f"staged System Mode recovery authorization: sha256={digest}") + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="kitsune", description="KitsuneMagisk host tooling") subcommands = parser.add_subparsers(dest="command", required=True) @@ -114,6 +143,7 @@ def build_parser() -> argparse.ArgumentParser: doctor.add_argument("--output", help="write canonical JSON to this path") doctor.add_argument("--init-import-proven", action="store_true") doctor.add_argument("--snapshot-id") + doctor.add_argument("--backup-location") doctor.add_argument("--backup-digest") doctor.add_argument("--restore-command") doctor.add_argument("--recovery-verified", action="store_true") @@ -133,6 +163,17 @@ def build_parser() -> argparse.ArgumentParser: report = system_commands.add_parser("validate-report", help="validate a stored doctor report") report.add_argument("path") report.set_defaults(handler=_validate_report) + + authorize = system_commands.add_parser( + "authorize", + help="stage a verified doctor report for one explicit System Mode install", + ) + authorize.add_argument("report", help="supported doctor JSON with verified external recovery") + authorize.add_argument("--adb", default="adb", help="ADB executable") + authorize.add_argument("--serial", help="existing ADB serial") + authorize.add_argument("--connect", help="ADB endpoint or port to connect before staging") + authorize.add_argument("--timeout", type=int, default=15) + authorize.set_defaults(handler=_authorize) return parser diff --git a/tools/system_mode/schemas/doctor-v1.schema.json b/tools/system_mode/schemas/doctor-v1.schema.json index fac0b8adf..265d8edaa 100644 --- a/tools/system_mode/schemas/doctor-v1.schema.json +++ b/tools/system_mode/schemas/doctor-v1.schema.json @@ -183,9 +183,10 @@ "recovery": { "type": "object", "additionalProperties": false, - "required": ["snapshot_id", "backup_digest", "restore_command", "verified"], + "required": ["snapshot_id", "backup_location", "backup_digest", "restore_command", "verified"], "properties": { "snapshot_id": {"type": ["string", "null"]}, + "backup_location": {"type": ["string", "null"]}, "backup_digest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, "restore_command": {"type": ["string", "null"]}, "verified": {"type": "boolean"} @@ -196,6 +197,7 @@ "then": { "properties": { "snapshot_id": {"type": "string", "minLength": 1}, + "backup_location": {"type": "string", "minLength": 1}, "backup_digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "restore_command": {"type": "string", "minLength": 1} } diff --git a/tools/system_mode/schemas/install-manifest-v1.schema.json b/tools/system_mode/schemas/install-manifest-v1.schema.json index 95db5bca8..61876882f 100644 --- a/tools/system_mode/schemas/install-manifest-v1.schema.json +++ b/tools/system_mode/schemas/install-manifest-v1.schema.json @@ -12,12 +12,13 @@ "product": { "type": "object", "additionalProperties": false, - "required": ["name", "version", "source_commit", "upstream_base"], + "required": ["name", "version", "source_commit", "upstream_base", "artifact_sha256"], "properties": { "name": {"const": "KitsuneMagisk"}, "version": {"type": "string"}, "source_commit": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, - "upstream_base": {"type": "string"} + "upstream_base": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "artifact_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} } }, "target": { From 6178c5ec78a8ff1efc3e840b4e702f85248c3517 Mon Sep 17 00:00:00 2001 From: Jordan Ye Date: Sat, 1 Aug 2026 13:08:08 -0400 Subject: [PATCH 2/3] docs(system-mode): record PR5A and PR5B qualification --- DEVELOPMENT_ROADMAP.md | 169 +++++--- compatibility/README.md | 15 +- compatibility/initial-lab-matrix.json | 8 +- .../2026-08-01-mumu-1.4.46-pr5a-pr5b.json | 375 ++++++++++++++++++ docs/status.md | 49 ++- docs/system-mode/contract.md | 2 +- docs/system-mode/failure-injection.md | 18 +- docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md | 164 ++++++++ 8 files changed, 701 insertions(+), 99 deletions(-) create mode 100644 compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json create mode 100644 docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md diff --git a/DEVELOPMENT_ROADMAP.md b/DEVELOPMENT_ROADMAP.md index 11946f853..3de80eb5d 100644 --- a/DEVELOPMENT_ROADMAP.md +++ b/DEVELOPMENT_ROADMAP.md @@ -12,7 +12,7 @@ > > Product charter: KitsuneMagisk exists primarily to provide persistent Magisk through **Direct-System/System Mode** on environments where normal boot-image installation is unavailable or impractical—especially commercial Android emulators—and secondarily to provide Kitsune-specific hiding and module behavior. -> Implementation progress reconciled for the PR #26 range on 2026-08-01 UTC. The repository's default/mainline branch is `kitsune` (`origin/HEAD` points to `origin/kitsune`); there is intentionally no separate branch named `main`. Roadmap PR1 merged as [GitHub #22](https://github.com/Jordan231111/KitsuneMagisk/pull/22); PR2 merged as [GitHub #23](https://github.com/Jordan231111/KitsuneMagisk/pull/23). PR3 (`e3fa3e829`), PR4 (`8e4e952b6`), and PR4A (`52aaf1658` plus follow-ups) are in the remote `kitsune` history. PR4B merged as [GitHub #24](https://github.com/Jordan231111/KitsuneMagisk/pull/24), bringing those prerequisite commits with it. PR3's reusable characterization exit is met, but full writable-target lifecycle qualification remains open. GitHub PR #26 is the review/merge unit represented by this roadmap commit: it completes current-line release/data containment and implements the target-independent safety portion of roadmap PR5 while keeping release System Mode unreachable. It does **not** prove persistent System Mode support or finish a power-loss-safe install/uninstall transaction. If this roadmap commit is already reachable from `kitsune`, treat PR5 as merged and begin PR5A; otherwise PR #26 still requires its exact-head merge gate. +> Implementation progress reconciled for the PR5A/PR5B branch on 2026-08-01 UTC. The repository's default/mainline branch is `kitsune` (`origin/HEAD` points to `origin/kitsune`); there is intentionally no separate branch named `main`. Roadmap PR1 merged as [GitHub #22](https://github.com/Jordan231111/KitsuneMagisk/pull/22); PR2 merged as [GitHub #23](https://github.com/Jordan231111/KitsuneMagisk/pull/23). PR3 (`e3fa3e829`), PR4 (`8e4e952b6`), and PR4A (`52aaf1658` plus follow-ups) are in the remote `kitsune` history. PR4B merged as [GitHub #24](https://github.com/Jordan231111/KitsuneMagisk/pull/24), bringing those prerequisite commits with it. GitHub PR #26 merged the current-line release/data containment and target-independent PR5 safety work. This branch completes the PR5A same-target MuMu comparison and the conditional PR5B durable current-line transaction. It deliberately keeps release System Mode unreachable and records an experimental exact-version result rather than promoting a stable release. After this branch merges, PR6 is the next engineering unit. > The explicit history-backed purpose, install-route, and divergence report is > [`docs/faithfulness-audit.md`](docs/faithfulness-audit.md). It covers the complete reachable graph, @@ -35,16 +35,16 @@ execution order. | 3 | Roadmap PR3 — System Mode contract and read-only characterization | **Integrated into `kitsune`** | Reuse its schemas/driver for all current/next comparisons. | | 4 | Roadmap PR4 and PR4A — data/update containment and portable AVD lab | **Integrated into `kitsune`; corrected further by PR #26** | Do not revert the rollback-compatible database design or safe SDK/AVD restoration. | | 5 | Roadmap PR4B / GitHub #24 — upstream/security laboratory | **Merged** | Regenerate the ledger when upstream refs or release candidates change. | -| 6 | Roadmap PR5 / GitHub #26 — production hardening and experimental System Mode containment | **Implemented in this two-commit range; exact-head gate/merge state is external** | If this commit is not yet on `kitsune`, merge only after the exact two-commit head is green. If it is reachable from `kitsune`, this item is complete. | -| 7 | Roadmap PR5A — one writable-target baseline-versus-hardened lifecycle | **Next engineering PR** | Qualify one exact snapshot-capable target through install, three cold boots, upgrade, reinstall, module/root smoke, uninstall, and restore. | -| 8 | Roadmap PR5B — conditional current-line durable transaction | **Not started; conditional** | Implement only the gaps required to ship the old core. Otherwise carry the failing tests and contract into PR7. | -| 9 | Roadmap PR6 onward — stable-base forward-port, parity, product features, and release | **Not started** | Begin only after PR5A provides a trustworthy comparison target and the PR5B branch decision is explicit. | +| 6 | Roadmap PR5 / GitHub #26 — production hardening and experimental System Mode containment | **Merged** | Preserve its release/data containment and debug-only System Mode boundary. | +| 7 | Roadmap PR5A — one writable-target baseline-versus-hardened lifecycle | **Implemented on this branch; merge-gated** | Preserve the exact MuMu 1.4.46 evidence and experimental/non-release boundary. | +| 8 | Roadmap PR5B — conditional current-line durable transaction | **Implemented on this branch; merge-gated** | Keep its tests/manifest contract as the behavioral oracle for PR7; do not turn the old core into a second permanent line. | +| 9 | Roadmap PR6 onward — stable-base forward-port, parity, product features, and release | **Next after this branch merges** | Re-resolve official stable and create the pristine maintained-base baseline before porting features. | -The next engineering task once PR #26 is present on `kitsune` is therefore **PR5A, not PR6 and not more speculative installer -code**. PR5A is evidence-first: use one existing, recoverable emulator instance or image at a time, -restore the same baseline between artifacts, and separate emulator-host boot failures from Android or -Magisk failures. BlueStacks Air `Tiramisu64` at `127.0.0.1:5555` is useful negative and ordinary-root -evidence, but its read-only system mount does not satisfy the writable System Mode exit. +The next engineering task after this PR5A/PR5B branch merges is **PR6**: re-resolve the latest +audited official stable and create the pristine `next-system` baseline. PR5A used only the existing +MuMu VM index 0, restored the same byte-verified baseline between comparisons, and separated player, +ADB, Android, and Magisk failures. PR5B now supplies the current-line manifest/recovery oracle that +PR7 must port rather than reinvent. ### Current hardening evidence and limits @@ -99,9 +99,17 @@ evidence, but its read-only system mount does not satisfy the writable System Mo - Three Gradle tasks that were permanently `NO-SOURCE` in the shared/native/stub modules were removed from the hosted command. The real app JVM tests and app/shared/stub lint lanes remain; this removes empty task scheduling, not test coverage. -- PR #26 improves staging and in-process rollback, but there is still no versioned persistent - ownership manifest or deterministic recovery after process death/power loss across system files, - `/data/adb/magisk`, addon survival, upgrade, and uninstall. Do not call that transaction complete. +- PR5B adds the versioned persistent ownership/original/journal/receipt set, exact target and artifact + binding, durable publication, boot verification, interrupted-state recovery, and manifest-owned + uninstall. Host faults cover ENOSPC, EROFS, short write, rename/fsync, process death, and reboot; + MuMu also completed a live staged-process-death rollback. Every boundary has not been power-cut on + every target, so this remains experimental qualification rather than a release claim. +- PR5A's exact MuMuPlayer 1.4.46 record covers the same-instance released comparison, restored + hardened runs, three candidate cold boots/player cycles, root policy, a minimal module, + upgrade/reinstall, exact uninstall, and verified external restoration. The final cleanup-only head + then passed upgrade, a BOOT_VERIFIED cold boot, root, staging-residue proof, exact uninstall, and + another verified restore. See + [`docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md`](docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md). - The old released certificate came from a publicly exposed historical test keystore. PR #26 removes that keystore and restores release identity checks, but the first production identity and its explicit upgrade/reinstall transition remain future release work. @@ -350,10 +358,12 @@ Add a read-mostly preflight command with human and `--json` output. The manager - [x] Parse `/proc/self/mountinfo`; report the real source, filesystem, mount flags, device-mapper layer, and slot for `/`, `/system`, `/vendor`, `/odm`, `/product`, and `/system_ext`. - [x] Detect EROFS, squashfs, shared-block ext4, overlayfs, dynamic partitions, dm-verity, and AVB/verified-boot state. - [x] Distinguish “currently writable overlay” from “persistent backing image is writable.” Prove persistence only through a controlled probe plus cold boot, then remove the probe. -- [ ] Locate every candidate init import directory and verify whether a harmless marker RC is parsed on a disposable snapshot before installing root services. +- [x] Locate every candidate init import directory and require external proof that a harmless marker + RC is parsed on the disposable target before authorizing root-service installation. - [x] Locate live, precompiled, monolithic, and split SELinux policy sources and their validation/hash metadata. - [x] Verify at least 32 MiB of safe staging capacity rather than writing a 20 MB zero file directly into the final system target. -- [ ] Verify the system image/snapshot backup location, free space, digest, and restore command before the first mutation. +- [x] Bind the system image/snapshot backup location, digest, and restore command into authorization; + PR5A verified host space and exercised the exact recovery tuple before mutation. - [x] Return stable reason codes such as `NO_BOOTSTRAP_ROOT`, `READ_ONLY_FS`, `EROFS`, `VERITY_ACTIVE`, `INIT_IMPORT_UNPROVEN`, `SEPOLICY_UNSUPPORTED`, `NO_RECOVERY_PATH`, and `SUPPORTED`. - [ ] Make the UI explain the failed capability and supported alternative; never show a generic “system is read-only” for every layout. @@ -365,38 +375,42 @@ needed to make the project’s core purpose dependable. Status labels were recon #### Release-blocking issues -1. **Open — no writable System Mode CI or maintained compatibility matrix.** Host tests now cover - schemas, gating, parsing, preflight, and rollback helpers; API 23/29/35 AVD jobs still test - ramdisk/boot-style setup, not a persistent `direct_install_system` lifecycle. +1. **Partially closed by PR5A — one writable target is now recorded.** MuMuPlayer 1.4.46 completed + the same-instance released/current comparison, persistent lifecycle, uninstall, and external + restore. It is an experimental exact-version record, not broad commercial-emulator CI or a + maintained cross-vendor compatibility matrix. API 23/29/35/36 AVD jobs remain ordinary + ramdisk/boot-style coverage rather than persistent `direct_install_system` qualification. 2. **Fixed in PR #26 — an explicitly selected recovery System Mode path required a boot image.** `scripts/flash_script.sh` now runs `find_boot_image` only for the normal boot-image route; a selected System Mode route reaches its debug-payload gate without requiring `BOOTIMAGE`. -3. **Open — recovery activation still accepts filename magic.** A ZIP/APK path containing - `systemmagisk` silently changes installation mode. Replace it with an explicit visible option and - confirmation. +3. **Fixed in PR5B — recovery activation is explicit.** Filename-substring activation is removed; + only explicit `SYSTEMMODE` selection enters the shared complete transaction and bypasses ordinary + boot-image discovery. 4. **Fixed in PR #26 — the live SELinux probe changed policy.** The installer now parses/saves the live policy without applying `permissive su`. Persistent next-boot policy selection remains open. -5. **Open — static SELinux selection patches only the first matching file.** Modern - split/precompiled policy selection can depend on platform/vendor inputs and matching hashes. A - successful write to one candidate does not prove init will load it. -6. **Partially mitigated, still open — installation is not durably transactional.** PR #26 stages - selected runtime/addon/init/policy sidecars and can reverse an in-process failure, but backups are - still on modified filesystems and no persistent journal recovers the full transaction after - process death or power loss. -7. **Open — uninstall ownership is too broad.** Wildcards such as `*magisk*` under init directories - can delete files not created by this exact installation. No runtime versioned install manifest - lists owned paths and original digests. +5. **Implemented in PR5B; enforcing-target breadth remains open.** The transaction records and + restores the actual disabled/live/precompiled/monolithic/split policy strategy instead of + patching the first filename match. MuMu proved live plus precompiled selection while permissive; + split and enforcing layouts remain PR7/PR15 qualification work. +6. **Fixed for the current-line transaction in PR5B.** A persistent receipt, original inventory, + ownership list, ordered journal, external recovery tuple, fsync/rename boundaries, boot proof, + and reverse recovery cover process death across install, upgrade, boot verification, and + uninstall. Live MuMu staged-process-death recovery passed; exhaustive live power cuts remain a + release-matrix item. +7. **Fixed in PR5B — uninstall is manifest-owned and hash-aware.** It refuses modified or + unrecognized state, restores exact originals, removes only recorded paths, and preserves files + outside the manifest. Wildcard Magisk deletion is absent from the System Mode path. 8. **Fixed in PR #26 — native context-switch I/O was unsafe.** `--auto-selinux` now uses checked `setcon`, a fresh bounded descriptor read, the actual read length, and deterministic close. PR7 should still prefer maintained upstream bootstrap primitives where possible. -9. **Open — `/sbin` remains an important hard-coded runtime assumption.** The Nox Android 12 fix - proves this is a compatibility fault line; current official live setup selects `/sbin` or - `/debug_ramdisk` by layout. +9. **Fixed in PR5B — runtime selection is layout-aware.** The authorization and receipt bind the + selected `/sbin` or `/debug_ramdisk` strategy; MuMu cold-booted the `/debug_ramdisk` path. 10. **Contained, not proven — Android 6/API 23–24 System Mode persistence is unproven.** PR #26 rejects these APIs for System Mode while retaining ordinary Magisk support. Re-enable only after a dedicated persistent launch path cold-boots successfully. -11. **Open — System Mode ownership/detection remains heuristic.** Persist an explicit - mode/schema/install ID and refuse destructive cleanup when ownership cannot be proven. +11. **Fixed in PR5B — ownership/detection is explicit.** The state directory records schema, + install/transaction IDs, target and source identity, owned/original paths, and manifest copies; + upgrade and uninstall refuse destructive work when that receipt cannot be proven exact. 12. **Partially mitigated, still open — the UI lacked capability truth.** PR #26 makes the action debug-only, adds a destructive warning, and the tested read-only target rejects before persistent mutation. Full doctor result/adapter/recovery integration and already-installed/conversion states @@ -405,20 +419,23 @@ needed to make the project’s core purpose dependable. Status labels were recon #### Required characterization and conditional current-branch fixes - [x] Add characterization tests around the current behavior before changing it. -- [ ] Extract System Mode shell logic from the oversized manager resource into a separately linted/tested script with a versioned interface. +- [x] Extract System Mode shell logic from the oversized manager resource into a separately linted/tested script with a versioned interface. - [x] Implement the read-only doctor, schemas, reason codes, ADB driver, debug-only warning, and explicit confirmation. -- [ ] Require and verify an external backup/snapshot and restore command before the first persistent mutation. -- [ ] Replace filename magic and SHA1 inference with an explicit `SYSTEM_MODE_SCHEMA` and install manifest. -- [ ] Stage all new files, calculate digests, validate policy/init output, and commit with a journal. On failure, roll back in reverse order and verify the original digests. -- [ ] Write backups outside the mutated image when possible; never claim recovery until a restore has been exercised. +- [x] Require and verify an external backup/snapshot and restore command before the first persistent mutation. +- [x] Replace filename magic and SHA1 inference with an explicit mode, schema, and install manifest. +- [x] Stage all new files, calculate digests, validate policy/init output, and commit with a journal. On failure, roll back in reverse order and verify the original digests. +- [x] Write backups outside the mutated image when possible; never claim recovery until a restore has been exercised. - [x] Replace the permissive live-policy probe with a non-mutating parse/save check on the current line. -- [ ] Replace wildcard uninstall with manifest-owned exact paths and hash-aware restoration. +- [x] Replace wildcard uninstall with manifest-owned exact paths and hash-aware restoration. - [x] Fix and unit-test the `--auto-selinux` context and bounded I/O behavior on the current line; prefer removing the custom option during the upstream-based port if maintained primitives suffice. -- [ ] Make runtime tmpfs selection layout-aware using the maintained upstream live-setup logic. -- [ ] Separate System Mode from dynamic `/system/bin` SU visibility so persistence can be tested without Hide/SuList complexity. -- [ ] Add shell static analysis and failure-injection tests after every mutation boundary. +- [x] Make runtime tmpfs selection layout-aware for `/sbin`, `/debug_ramdisk`, and an + adapter-authorized location; PR7 should still prefer the maintained upstream live-setup primitive. +- [x] Test System Mode persistence independently from Hide/SuList; dynamic `/system/bin` visibility + remains a separate provider/hiding question. +- [x] Add shell static analysis and durable-boundary failure-injection tests. Repeat every boundary + live on each advertised target before release. ### v30.7 System Mode vertical-slice design @@ -461,9 +478,9 @@ Start with current official code, but reuse current Kitsune behavior and fixture | Latest Kitsune CI and local AVD evidence | [PR #26 run 30697832776](https://github.com/Jordan231111/KitsuneMagisk/actions/runs/30697832776) passed source, build/JVM, API 23/29/35, and aggregate product gates at pre-final two-commit head `fc10d9242`, including the Android 6 readiness fix. Its paired security run exposed only volatile global RustSec metadata and led to the final semantic-comparison fix. Pre-final artifact head `1cac2135e` passed local official ARM64 API 34/35/36 debug and release patched-ramdisk boots, manager setup/reboot/self-test/root, 32 concurrent `su` calls per artifact, 137-case parser/policy/signing corpus per artifact, byte restoration, and AVD deletion. Final code commit `530f2a3f8` adds no app/native change beyond that product-tested content; it passed the 99-test local host suite and a fresh semantic RustSec check. The earlier [PR4A lab record](docs/system-mode/avd-lab-2026-07-22.md) retains immutable API 35 16 KiB/API 36 negative evidence. | Ordinary Magisk integration is evidenced on hosted x86_64, local ARM64 Android 14–16, and the exact BlueStacks comparison target. The exact two-commit hosted run after the semantic RustSec fix is the mandatory merge record; none of these normal-install lanes substitutes for writable System Mode qualification. | | Local build | The pinned ONDK is installed; canonical debug/minified-release builds and Gradle debug native links pass for ARM64, ARM32, x86_64, and x86 on this Mac. Final testing found that Gradle's `NDK_DEBUG=1` omitted section GC and pulled dead ARMv7 unwind code; `Application.mk` now makes the canonical and Gradle link contracts explicit and the formerly failing ARMv7 path passes. | Preserve the exact toolchain/bootstrap checks so another maintainer can reproduce the result. | | Local submodules | All current Kitsune submodules are initialized at their recorded gitlinks. A separate full recursive official-Magisk clone also checked out every current upstream submodule. | Recursive checkout remains a documented prerequisite; PR4B now automates reachability and pin drift. | -| Tests | PR #26's squashed local candidate passed 99 host tests, JVM tests, zero-error lint, shell/source checks, clean all-ABI debug/release builds, artifact identity/signing checks, same-instance BlueStacks backend comparisons, official Android 14–16 ARM64 lifecycles, and API 35 provider/module/HideList/hidden-manager characterization. Three always-`NO-SOURCE` Gradle test tasks were removed while the real app JVM tests and all app/shared/stub lint lanes remain. | The local module lane closes basic startup/hook questions but exposes an external-provider mount-cleanup gap. SuList, early-mount, broad module compatibility, and actual writable Direct-System install/upgrade/uninstall remain release blockers. Heavy stress stays local; retained CI regressions are bounded and high-yield. | +| Tests | PR #26's squashed local candidate passed 99 host tests, JVM tests, zero-error lint, shell/source checks, clean all-ABI debug/release builds, artifact identity/signing checks, same-instance BlueStacks backend comparisons, official Android 14–16 ARM64 lifecycles, and API 35 provider/module/HideList/hidden-manager characterization. PR5A/PR5B expands the host suite to 126 tests, passes exact all-ABI debug/release artifact checks, repeats ordinary debug/release lifecycles on temporary ARM64 API 23 and API 36 AVDs, and completes the exact MuMu evidence described below. | The writable current-line oracle now exists, but it remains debug-only and exact-version experimental. SuList, early-mount, broad module compatibility, enforcing/multi-target System Mode, production identity, and the maintained-base port remain release blockers. Heavy stress stays local; retained CI regressions are bounded and high-yield. | | Primary product feature | System Mode originated in `05289fb5` and now spans manager UI, shell/recovery installation, native tmpfs setup, policy, init, persistence, and uninstall | It must be treated as the branch-selection and release-qualification gate, not an optional later experiment | -| System Mode test coverage | Host tests exercise installer parsing, debug/release gating, preflight, private mount namespace behavior, and rollback functions; live BlueStacks testing proved the debug warning and read-only rejection without changing selected init/policy hashes. No CI or qualified writable target completes a persistent `direct_install_system` lifecycle. | Use PR5A to qualify one writable target, then implement only evidence-backed transaction gaps. Characterization and negative refusal are not install qualification. | +| System Mode test coverage | Host tests exercise authorization, target/source binding, manifest/state parsing, debug/release gating, private mount behavior, fsync/atomic fault classes, reverse recovery, boot verification, upgrade, and exact uninstall. Live BlueStacks still proves read-only refusal. MuMuPlayer 1.4.46 now has the same-instance released/current comparison, three candidate cold boots, player cycles, root policy, a minimal module, reinstall, staged-process-death rollback, exact-final upgrade/boot/uninstall, and two byte-verified external restores. | Preserve MuMu as an exact experimental record, not a brand-level support claim. Use this current-line transaction and its failures as the PR7 oracle on the maintained official base; PR15 must repeat the full exact-artifact matrix across every advertised target. | | Official reusable emulator logic | Magisk v30.7 `scripts/live_setup.sh` supports API 23–36 and handles legacy `/sbin` versus modern `/debug_ramdisk` runtime setup | The v30.7 port can replace several old custom primitives; it is a bounded vertical slice, not a from-scratch Magisk rewrite | | PR checks | PR2 added pull-request checks, static/host/JVM tests, debug/release builds, API 23/29/35 AVD jobs, and an aggregate product gate. | Stable qualification is still broader, but ordinary code changes no longer lack a build/boot gate. | | Update service | The inherited `1q23lyc45.github.io` channels are dead; PR4 now resolves every built-in channel to an explicit unavailable result without a request. Custom metadata requires HTTPS and cannot redirect to cleartext. | A project-owned, digest-validated service remains PR10; containment is complete for the current line. | @@ -1890,7 +1907,7 @@ Measured code-head qualification: no code-head APK above may be promoted directly; the final two-commit history must rebuild and pass the hosted identity/build/AVD/security gates before merge. -Explicit non-claims and remaining limits: +Explicit non-claims and remaining limits at the PR #26 merge, before the PR5A/PR5B follow-up: - No writable target completed persistent System Mode install/cold-boot/upgrade/uninstall. - Rollback is not power-loss atomic across the persistent system payload, `/data/adb/magisk`, @@ -1908,8 +1925,10 @@ pre-merge commit is promoted as the first production release. ## PR 5A — One writable-target baseline-versus-hardened lifecycle -**Implementation status: next engineering PR once this GitHub #26 range is reachable from -`kitsune`. Not started.** +**Implementation status: implemented on `codex/pr5a-mumu-lifecycle`; merge-gated.** The complete +record is +[`docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md`](docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md), +with a machine-readable experimental record under `compatibility/records/`. This is deliberately evidence-first and should be small in repository code. Use one exact snapshot-capable LDPlayer, MuMu, Nox, custom writable Android image, or other controlled target. @@ -1941,9 +1960,20 @@ comparison artifact into the same restored instance or identical byte-verified s verified restore. If no target passes, publish the precise blocker and keep System Mode experimental; do not manufacture a successful support claim. +**Result:** MuMuPlayer for macOS 1.4.46, VM index 0, Android 12/API 32 ARM64 with writable ext4 +`/system` satisfied the engineering exit. The released comparison and current feature set were +re-injected into the same externally restored VM; candidate runs completed three cold boots/player +cycles, root policy, a minimal module, reinstall, live staged-process-death rollback, uninstall, +and restore. The exact final cleanup-only head then completed upgrade, `BOOT_VERIFIED`, root, exact +uninstall, and another verified restore. The record stays `experimental`: its starting snapshot +contained released legacy System Mode, only one cold boot was repeated after the final amendment, +and release System Mode remains unavailable. + ## PR 5B — Conditional current-line durable System Mode transaction -**Implementation status: not started; decision follows PR5A.** +**Implementation status: implemented on `codex/pr5a-mumu-lifecycle`; merge-gated.** PR5A proved a +current-line comparison oracle was useful, so the conditional Path B was taken without promoting +the old core as a permanent release line. Do this on the old core only if PR5A shows that a current-`kitsune` build is a necessary release or comparison candidate. Otherwise move the target-backed failing tests and requirements into PR7 and @@ -1977,11 +2007,19 @@ duplicate dependency PR is created. the work is explicitly superseded by PR7 with every failing test preserved. “Interim rollback seems to work” is not an exit. +**Result:** the current line now uses one shared complete installer/uninstaller with a versioned +receipt, ownership/original inventories, JSON manifest, ordered journal, target/source/artifact and +external-recovery binding, layout-aware runtime/init/policy selection, durable atomic publication, +boot-ID verification, deterministic interrupted-state recovery, and exact hash-aware uninstall. +The 126-test host suite covers the complete required fault classes; live MuMu proves STAGED process +death recovery and the manifest-owned lifecycle. Every live power-cut boundary and real OTA/addon +cycle remain PR15/PR16 qualification work, not hidden claims of this exit. + ## PR 6 — Pristine latest-audited-stable `next-system` baseline -**Implementation status: not started. Depends on PR #26 merge, PR5A evidence, and an explicit PR5B -ship/supersede decision.** The official API still reports v30.7 as latest stable on 2026-08-01, but -the release must be resolved again at the actual branch cut. +**Implementation status: next engineering PR after the PR5A/PR5B branch merges.** The official API +reported v30.7 as latest stable when rechecked on 2026-08-01, but the release must be resolved again +at the actual branch cut. - Re-check official releases at branch cut; if v30.7 remains latest stable, create `next-system` from `e8a58776...`. If not, record the newer candidate and rerun the upstream/security/port-feasibility @@ -2195,7 +2233,8 @@ No release should be called stable until all applicable boxes are checked. - [ ] Upgrade from current `31000` path tested or reinstall requirement clearly enforced/documented. - [ ] Fresh install, upgrade, rollback behavior, uninstall, and stock restoration tested. - [ ] API 23, 29, modern stable, and physical device matrix pass. -- [ ] API 23–24 System Mode is either cold-boot proven or excluded from System Mode support even if normal Magisk still supports those APIs. +- [x] API 23–24 System Mode is excluded while normal Magisk API 23 support remains tested; re-enable + only after a dedicated persistent launch path passes cold boots. - [ ] 16 KiB page, `init_boot`, `vendor_boot`, SAR/2SI cases pass as claimed. - [ ] MagiskHide/SuList migration and behavior pass. - [ ] Chosen Zygisk provider model and representative module pass. @@ -2225,22 +2264,20 @@ in the detail: first-class compatibility requirements. - **Done foundation:** add the shared System Mode doctor, manifest/state schemas, ADB harness, reason codes, fixtures, and failure-injection contract before a forward-port. -- **Done in this range; externally gated:** GitHub PR #26 contains the current-line - release/data/runtime hardening and experimental System Mode containment. If this roadmap commit is - not yet reachable from `kitsune`, land it only after its exact amended head is green; otherwise - proceed to PR5A. -- **Next — PR5A:** qualify one exact writable, recoverable target by reinjecting the released - comparison and merged hardened artifact into the same restored instance/snapshot. BlueStacks - read-only refusal is useful but does not satisfy this result. -- **Conditional — PR5B:** finish explicit recovery mode, layout-aware runtime, persistent manifest, - external backup, crash/power-loss recovery, and exact uninstall on the old core only if PR5A proves - a current-line release is needed. Otherwise preserve the tests for PR7. +- **Done:** GitHub PR #26 contains the current-line release/data/runtime hardening and experimental + System Mode containment. +- **Done on this merge-gated branch — PR5A:** MuMuPlayer 1.4.46 has the same-instance + released-versus-current lifecycle and verified external restore record. Keep it exact-version and + experimental; BlueStacks read-only refusal remains negative evidence. +- **Done on this merge-gated branch — PR5B:** explicit recovery routing, layout-aware runtime, + persistent manifest/receipt/journal, external-backup authorization, interrupted-state recovery, + boot verification, and exact uninstall now form the current-line behavioral oracle for PR7. - **Done for current data containment:** keep `denylist` canonical and reconcile existing legacy v12 data once without a fake public schema bump. **Still open:** provider/process/SuList semantics and the eventual `next-system` schema. - **Done foundation:** PR build/test CI and the upstream/security laboratory run on disposable hosted infrastructure; commercial-emulator mutation remains manually controlled and snapshot-backed. -- **Open — PR6/PR7:** re-check official stable, create the pristine latest-audited-stable baseline +- **Next — PR6/PR7:** re-check official stable, create the pristine latest-audited-stable baseline (v30.7 at this checkpoint), then port only the System Mode vertical slice first using maintained live-setup, policy, module, and runtime primitives. - **Open — PR8:** run identical snapshots against current and next implementations and select the diff --git a/compatibility/README.md b/compatibility/README.md index 23e409ee6..09b7e7103 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -4,11 +4,14 @@ Synthetic classifier fixtures live under `tools/system_mode/fixtures` and are never support claims. The initial required lanes are LDPlayer, MuMu, Nox, BlueStacks, and one immutable negative image. -PR3 recorded the connected MuMu 12 environment without mutating it. PR4A also recorded fresh -Android Studio API 35 (16 KiB pages) and API 36 immutable images as real fail-closed negative -evidence. LDPlayer, Nox, and BlueStacks remain `not_run`; they must not be converted to -`supported` from brand strings or synthetic data. +PR3 recorded the connected MuMu 12 environment without mutating it. PR5A/PR5B add an experimental +MuMuPlayer 1.4.46 lifecycle record backed by a verified external image restore; it remains +experimental because the starting image contained a legacy System Mode installation and the exact +final artifact did not repeat every predecessor module/failure-injection subcase. PR4A also recorded +fresh Android Studio API 35 (16 KiB pages) and API 36 immutable images as real fail-closed negative +evidence. LDPlayer and Nox remain `not_run`; no lane may be converted to `supported` from brand +strings or synthetic data. Support requires a clean snapshot plus install, three cold boots, upgrade, reinstall, module/root -smoke, uninstall, and snapshot restore. The current records intentionally preserve `not_run` for -every lifecycle operation that was not actually exercised. +smoke, uninstall, and snapshot restore on the exact artifact. Records intentionally preserve +`not_run` and `experimental` wherever that exact claim was not exercised. diff --git a/compatibility/initial-lab-matrix.json b/compatibility/initial-lab-matrix.json index 7bd5c5f1c..5d7b554a1 100644 --- a/compatibility/initial-lab-matrix.json +++ b/compatibility/initial-lab-matrix.json @@ -1,12 +1,12 @@ { "schema_version": 1, - "reviewed_at": "2026-07-22T04:44:20Z", + "reviewed_at": "2026-08-01T16:43:50Z", "lanes": [ { "family": "MuMu", - "status": "observed", - "record": "records/2026-07-22-mumu12-port16384-current-kitsune.json", - "reason": "ADB port 16384 exposed MuMu 12 engine 1.4.46 with a pre-existing legacy System Mode installation; the instance was not a clean snapshot." + "status": "experimental", + "record": "records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json", + "reason": "MuMuPlayer 1.4.46 completed the PR5A/PR5B released-versus-current lifecycle and verified external restore on VM index 0. The exact final debug artifact passed install/upgrade, BOOT_VERIFIED, root, exact uninstall, and restore; predecessor-only module and injected-failure subcases keep this below supported." }, { "family": "LDPlayer", diff --git a/compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json b/compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json new file mode 100644 index 000000000..4fca2de3b --- /dev/null +++ b/compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json @@ -0,0 +1,375 @@ +{ + "schema_version": 1, + "record_id": "2026-08-01-mumu-1.4.46-pr5a-pr5b", + "qualification_status": "experimental", + "clean_snapshot": false, + "artifact": { + "identity_status": "known", + "branch": "codex/pr5a-mumu-lifecycle", + "commit": "c86bdce439cba1b0317073a0a50dfda95dfde688", + "apk_sha256": "b84171fb47e54cd85248049a792cb1e12d2eabd42ccd042bb6049897c2e0b21a" + }, + "doctor": { + "assessment": { + "primary_reason": "SUPPORTED", + "reason_codes": [ + "SUPPORTED", + "AVB_STATE_UNKNOWN", + "SELINUX_NOT_ENFORCING", + "SYSTEM_MODE_ALREADY_INSTALLED" + ], + "verdict": "supported", + "warnings": [ + "AVB_STATE_UNKNOWN", + "SELINUX_NOT_ENFORCING", + "SYSTEM_MODE_ALREADY_INSTALLED" + ] + }, + "bootstrap": { + "adb_uid": 2000, + "magisk_version": "4a216671-kitsune:MAGISK:D", + "magisk_version_code": 31000, + "root_available": true, + "root_context": "u:r:magisk:s0", + "root_uid": 0, + "transport": "magisk_su" + }, + "device": { + "abis": [ + "arm64-v8a" + ], + "android_release": "12", + "api": 32, + "boot_id_sha256": "d9c3dc267839aa23f73464e0568a3fe7b6b76b3f8633ec64c2b13e5abcea7c72", + "brand": "Samsung", + "build_id": "W528JS", + "build_incremental": "224", + "device": "SM-A5460", + "emulator_product": "MACPRO", + "emulator_product_property": "nemud.player_engine", + "emulator_version": "1.4.46", + "emulator_version_property": "nemud.player_version", + "fingerprint_sha256": "d3eec945343c1dc4b4bf2ea4a6caee01299b405bc66648b846a28ff9e8e3e778", + "kernel": "Linux localhost 4.19.195-android-arm64-g61d8c513109a #1 SMP PREEMPT Thu Aug 29 16:36:04 HKT 2024 aarch64", + "manufacturer": "Samsung", + "model": "SM-A5460", + "page_size": 4096, + "product": "SM-A5460", + "vendor": "mumu", + "vendor_evidence": [ + "mumu12shared", + "mumu" + ] + }, + "existing_install": { + "config_path": "/system/etc/init/magisk/config", + "detected": true, + "manifest_path": "/system/etc/init/magisk/install-manifest.json", + "manifest_present": true, + "system_mode": true + }, + "generated_at": "2026-08-01T16:43:50Z", + "init": { + "candidate_directories": [ + { + "exists": true, + "kind": "directory", + "path": "/system/etc/init", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/etc/init" + }, + { + "exists": true, + "kind": "directory", + "path": "/system/etc/init/hw", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/etc/init/hw" + }, + { + "exists": true, + "kind": "directory", + "path": "/vendor/etc/init", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/vendor/etc/init" + }, + { + "exists": false, + "kind": "missing", + "path": "/odm/etc/init", + "permission_writable": false, + "readable": false, + "resolved_path": "/system/vendor/odm/etc/init" + }, + { + "exists": false, + "kind": "missing", + "path": "/product/etc/init", + "permission_writable": false, + "readable": false, + "resolved_path": "/system/product/etc/init" + }, + { + "exists": false, + "kind": "missing", + "path": "/system_ext/etc/init", + "permission_writable": false, + "readable": false, + "resolved_path": "/system/system_ext/etc/init" + } + ], + "import_proof": "proven", + "selected_directory": "/system/etc/init" + }, + "layout": { + "avb_state": "unknown", + "device_mapper": { + "layers": [], + "probe": "not_applicable" + }, + "dm_verity": false, + "dynamic_partitions": false, + "ext4_features": [], + "ext4_features_method": "superblock", + "ext4_features_probe": "observed", + "ext4_ro_compat_flags": 123, + "overlayfs": false, + "shared_blocks": false, + "slot_suffix": "", + "system_fs_type": "ext4", + "system_source": "/dev/block/sda1", + "system_target": "/system", + "system_writable_backing": true, + "system_writable_view": true + }, + "mounts": [ + { + "exists": true, + "fs_type": "tmpfs", + "major_minor": "0:20", + "mount_options": [ + "ro", + "nodev", + "relatime" + ], + "mount_point": "/", + "resolved_path": "/", + "root": "/", + "source": "tmpfs", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/", + "writable_backing": true, + "writable_view": false + }, + { + "exists": true, + "fs_type": "ext4", + "major_minor": "8:1", + "mount_options": [ + "rw", + "noatime" + ], + "mount_point": "/system", + "resolved_path": "/system", + "root": "/", + "source": "/dev/block/sda1", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/system", + "writable_backing": true, + "writable_view": true + }, + { + "exists": true, + "fs_type": "ext4", + "major_minor": "8:1", + "mount_options": [ + "rw", + "noatime" + ], + "mount_point": "/system", + "resolved_path": "/system/vendor", + "root": "/", + "source": "/dev/block/sda1", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/vendor", + "writable_backing": true, + "writable_view": true + }, + { + "exists": true, + "fs_type": "tmpfs", + "major_minor": "0:20", + "mount_options": [ + "ro", + "nodev", + "relatime" + ], + "mount_point": "/", + "resolved_path": "/odm", + "root": "/", + "source": "tmpfs", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/odm", + "writable_backing": true, + "writable_view": false + }, + { + "exists": true, + "fs_type": "ext4", + "major_minor": "8:1", + "mount_options": [ + "rw", + "noatime" + ], + "mount_point": "/system", + "resolved_path": "/system/product", + "root": "/", + "source": "/dev/block/sda1", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/product", + "writable_backing": true, + "writable_view": true + }, + { + "exists": true, + "fs_type": "ext4", + "major_minor": "8:1", + "mount_options": [ + "rw", + "noatime" + ], + "mount_point": "/system", + "resolved_path": "/system/system_ext", + "root": "/", + "source": "/dev/block/sda1", + "super_options": [ + "rw", + "seclabel" + ], + "target": "/system_ext", + "writable_backing": true, + "writable_view": true + } + ], + "persistence": { + "backing_write_probe": "passed", + "cold_boots": 4, + "host_restarts": 3, + "proven": true + }, + "probe_version": "1.0.0", + "recovery": { + "backup_digest": "c62ca78e6326d0fdf8920e1c286705883c40ca161db4b2a622bd20c725654db2", + "backup_location": "/Users/jordan/Documents/pr5a-mumu-recovery.3ZYfC2/vm", + "restore_command": "/Applications/MuMuPlayer.app/Contents/MacOS/mumutool close 0; qemu-img check system.qcow2 and data.qcow2; atomically replace VM0 system.qcow2, data.qcow2, config.json, and setting/vm.json from /Users/jordan/Documents/pr5a-mumu-recovery.3ZYfC2/vm; sync; /Applications/MuMuPlayer.app/Contents/MacOS/mumutool open 0", + "snapshot_id": "mumu-vm0-external-byte-clone-c62ca78e6326", + "verified": true + }, + "schema_version": 1, + "selinux": { + "enabled": true, + "policy_candidates": [ + { + "exists": true, + "kind": "file", + "path": "/vendor/etc/selinux/precompiled_sepolicy", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/vendor/etc/selinux/precompiled_sepolicy", + "sha256": "f032c50d4ade574c6d092b14f341782d16a075acfcf2c56d082eac808d6ee0f3", + "size": 508390 + }, + { + "exists": true, + "kind": "file", + "path": "/system/etc/selinux/plat_sepolicy.cil", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/etc/selinux/plat_sepolicy.cil", + "sha256": "be0219f8be1eb9b03ca9aa74cb3cef86d3ad5fcf5dac05fc4967f3a1ac9e8ec6", + "size": 1816428 + }, + { + "exists": true, + "kind": "directory", + "path": "/system/etc/selinux/mapping", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/etc/selinux/mapping", + "sha256": null, + "size": null + }, + { + "exists": true, + "kind": "file", + "path": "/vendor/etc/selinux/vendor_sepolicy.cil", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/vendor/etc/selinux/vendor_sepolicy.cil", + "sha256": "c3e7b35512487eab0730483b2aaa1820b4b95afa4e1be212dfbbdf2ebcf714a5", + "size": 87954 + }, + { + "exists": true, + "kind": "file", + "path": "/vendor/etc/selinux/precompiled_sepolicy.plat_sepolicy_and_mapping.sha256", + "permission_writable": true, + "readable": true, + "resolved_path": "/system/vendor/etc/selinux/precompiled_sepolicy.plat_sepolicy_and_mapping.sha256", + "sha256": "575586cf4be3a5229db8cbc2040c0a81267ed6f67dae542c4cf800e467253d1c", + "size": 65 + } + ], + "state": "permissive", + "strategy": "precompiled" + }, + "source": { + "kind": "adb", + "probe_sha256": "b5d4ea0065a5e53237f3fb2d40eaea1c4e6b76716233793b451c9b48c60d8a47", + "read_only": true, + "repository_commit": "c86bdce439cba1b0317073a0a50dfda95dfde688", + "repository_dirty": false, + "serial_sha256": "da09b86341cf0b001201905615202d7df7d794dffe1abe531edd88eeede437bc" + }, + "staging": { + "available_bytes": 98400460800, + "path": "/data/local/tmp", + "required_bytes": 33554432, + "sufficient": true + } + }, + "lifecycle": { + "install": "passed", + "cold_boots": 1, + "upgrade": "passed", + "reinstall": "passed", + "module_smoke": "not_run", + "root_policy_smoke": "passed", + "uninstall": "passed", + "snapshot_restore": "passed" + }, + "notes": [ + "MuMuPlayer for macOS 1.4.46, VM index 0, Android 12/API 32, arm64-v8a, 4 KiB pages, writable ext4 /system on /dev/block/sda1, no observed dm-verity/dynamic/shared-block layer, permissive SELinux, precompiled policy, and vendor root disabled.", + "The exact c86bdce4 debug artifact upgraded the preceding transactional candidate, committed without staging residue, cold-booted to BOOT_VERIFIED on /debug_ramdisk, returned uid 0 in u:r:magisk:s0, uninstalled exact manifest-owned paths, removed its manager, and restored the external byte-verified baseline.", + "The same PR feature set at parent commit 4a216671 completed three cold boots/player stop-start cycles, root deny/allow persistence, a minimal module mount/removal, reinstall, and live staged-process-death rollback. The c86bdce4-only cleanup change was then proven by the before/after staging-residue check.", + "The restored starting image intentionally contained released 31.0-kitsune legacy System Mode, so clean_snapshot is false and this record is experimental rather than a production support claim.", + "Full ENOSPC, EROFS, short-write, rename, fsync, process-death, reboot, upgrade, and uninstall boundary coverage is retained in host tests; only staged process death was injected live on this MuMu target." + ] +} diff --git a/docs/status.md b/docs/status.md index efa036092..d81723dfc 100644 --- a/docs/status.md +++ b/docs/status.md @@ -7,17 +7,20 @@ but the resumed project has not shipped its first production release. The inheri a fork compatibility and Android upgrade-ordering value; it does not mean this branch is newer than official Magisk. -The current hardening work is based on `kitsune` at `bcdf65f0`. Official comparison points are -Magisk v30.7 (`e8a58776`) and the observed official `master` tip `fd0cb66b`. +The PR5A/PR5B work is based on merged `kitsune` at `f6beadd7`; its exact implementation head is +`c86bdce4`. Official comparison points rechecked on 2026-08-01 are Magisk v30.7 (`e8a58776`) and +the observed official `master` tip `fd0cb66b`. ## What currently works - Ordinary Magisk patch, emulator setup, manager initialization, root, and parser flows pass on the official Android 14, 15, and 16 ARM64 Emulator images covered by the project tests. Physical boot-image flashing still needs a recoverable-device qualification run. -- Direct-System/System Mode remains a separate, explicit debug-only action. It now warns before use, - checks the target before mutation, uses a private mount namespace, and rolls back staged files on - tested failures. It is not yet qualified as a release feature. +- Direct-System/System Mode remains a separate, explicit debug-only action. The current line now + requires a host-authorized supported doctor report and verified external recovery tuple, records a + durable ownership/original/journal manifest, verifies the first boot, recovers interrupted states, + and uninstalls only exact hash-verified owned paths. MuMuPlayer 1.4.46 completed the experimental + lifecycle below. This is not a stable release feature. - Existing Kitsune/Delta HideList data is reconciled once into the active DenyList table without changing the rollback-compatible database version or touching SuList. Fresh installs do not run a legacy-data migration. See [the migration note](hide-migration-v13.md). @@ -46,26 +49,40 @@ Host logs also reproduced BlueStacks process/ADB/storage-startup failures with a payload, so that observed intermittent case is strongly vendor-side. This does not prove that every future boot failure is unrelated to Magisk or installed modules. +The writable target is the existing Chinese MuMuPlayer for macOS 1.4.46 VM index 0: Android 12/API +32, ARM64, 4 KiB pages, writable ext4 `/system` on `/dev/block/sda1`, permissive SELinux with a +precompiled policy, and vendor root disabled. Released and current artifacts were tested on the same +externally restored VM. The current feature set passed three cold boots/player cycles, root policy, +a minimal module, reinstall, staged-process-death rollback, exact uninstall, and verified image +restore; the exact final `c86bdce4` artifact then passed upgrade, `BOOT_VERIFIED`, root, staging +cleanup, exact uninstall, and another restore. The record remains experimental because the baseline +contained released legacy System Mode and not every predecessor stress case was repeated after the +cleanup-only final amendment. See the +[PR5A/PR5B MuMu record](system-mode/mumu-pr5a-pr5b-2026-08-01.md). + +Temporary ARM64 Android 6/API 23 and Android 16/API 36 AVDs also passed debug and disposable-release +normal patched-ramdisk boot, manager, reboot, root, concurrent-`su`, and corpus lanes, then had their +stock ramdisks restored and were deleted. These are ordinary Magisk compatibility results, not +Direct-System qualification. API 23–24 System Mode remains excluded. + Support remains attached to an exact emulator/device version, Android image, ABI, page size, filesystem/layout, bootstrap method, and tested lifecycle. The detailed evidence ledger is in [`DEVELOPMENT_ROADMAP.md`](../DEVELOPMENT_ROADMAP.md). ## Release blockers -1. Finish manifest-owned, crash-recoverable System Mode install, upgrade, addon, uninstall, and - restore transactions. -2. Qualify System Mode on an exact writable, snapshot-capable target through install, three cold - boots, upgrade/reinstall, root/module checks, uninstall, and verified restoration. -3. Create a protected production signing identity and an explicit transition from APKs signed with +1. Forward-port the tested System Mode contract onto a freshly audited current official stable core; + do not maintain the old core as a second permanent release line. +2. Create a protected production signing identity and an explicit transition from APKs signed with the historical public test certificate. -4. Establish a project-owned authenticated update service before enabling built-in update channels. -5. Forward-port the tested Kitsune behavior onto an audited current official stable core; do not - independently merge hundreds of official `master` commits into this old core. -6. Select and qualify a Zygisk architecture and define HideList/SuList/provider behavior by exact +3. Establish a project-owned authenticated update service before enabling built-in update channels. +4. Repeat the complete exact-artifact System Mode lifecycle and every live failure boundary on each + advertised commercial-emulator/enforcing-policy/OTA lane; one MuMu version is not brand support. +5. Select and qualify a Zygisk architecture and define HideList/SuList/provider behavior by exact provider version. ReZygisk 1.0.0 must not be advertised as Kitsune-compatible. -7. Complete physical-device, commercial-emulator, ABI/runtime, recovery, multiuser, SELinux, +6. Complete physical-device, ABI/runtime, recovery, multiuser, SELinux, hidden-manager, safe-mode, and failure-injection matrices for every support claim. -8. Resolve or explicitly carry remaining dependency/security holds, including the RSA timing +7. Resolve or explicitly carry remaining dependency/security holds, including the RSA timing advisory with no fixed upstream version. The ordered implementation plan and acceptance criteria are maintained in the diff --git a/docs/system-mode/contract.md b/docs/system-mode/contract.md index 6fdc229d1..8c768b08d 100644 --- a/docs/system-mode/contract.md +++ b/docs/system-mode/contract.md @@ -79,7 +79,7 @@ UNINSTALLED -> PREFLIGHTED -> STAGED -> COMMITTED -> BOOT_VERIFIED state; the external restore path is mandatory. A retry is forbidden while a journal is in `ROLLBACK_REQUIRED`, `ROLLING_BACK`, or `FAILED`. -The future on-device ownership record must validate against +The current-line on-device ownership record validates against [`install-manifest-v1.schema.json`](../../tools/system_mode/schemas/install-manifest-v1.schema.json). It records full source identity, adapter, payload digests, exact original paths and metadata, external backup, strategies, and a monotonically ordered mutation journal. Wildcard ownership is diff --git a/docs/system-mode/failure-injection.md b/docs/system-mode/failure-injection.md index c4b3a438b..707f30b0c 100644 --- a/docs/system-mode/failure-injection.md +++ b/docs/system-mode/failure-injection.md @@ -1,8 +1,11 @@ # System Mode failure-injection plan -This plan defines the complete mutation boundaries that PR5B/PR7 installers must expose. PR #26 -hardens and fault-tests the current legacy transaction ordering, but it does not yet implement the -durable manifest/journal or every crash boundary below. +This plan defines the complete mutation boundaries exposed by the PR5B current-line transaction and +required again by the PR7 maintained-base port. PR5B implements the durable receipt, +manifest/journal, reverse recovery, fsync/rename boundaries, and exact uninstall. Its host suite +exercises the complete fault classes below; the 2026-08-01 MuMu qualification injected staged +process death live. Every boundary still requires live repetition on each target before a public +release claim. Run every case from a disposable snapshot with an external backup whose digest and restore command have already been verified. For each boundary, terminate the installer immediately after the @@ -29,9 +32,12 @@ digests and metadata. Then exercise the external restore even when automatic rol ## Injection interface -The future installer accepts a development-only `KITSUNE_FAIL_AFTER=` value. Release -builds must ignore or reject that environment variable unless an internal test flavor is enabled. -Each boundary is emitted only after the prior operation and journal record are both durable. +The current transaction accepts the development-only +`KITSUNE_SYSTEM_MODE_FAIL_AT=:` form for `enospc`, `erofs`, `short-write`, +`fsync-file`, `fsync-parent`, `rename`, `process-death`, and `reboot`, plus an exact boundary name +for a normal injected failure. The release manager does not expose System Mode, and qualification +must use a debug artifact with explicit host authorization. Each boundary is emitted only after the +prior operation and journal record are durable. The harness records: diff --git a/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md b/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md new file mode 100644 index 000000000..bd7605a9f --- /dev/null +++ b/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md @@ -0,0 +1,164 @@ +# PR5A/PR5B MuMu lifecycle and durable-transaction record — 2026-08-01 + +This record closes the evidence and current-line implementation work assigned to roadmap PR5A and +PR5B. It does not promote System Mode to a stable release feature. The tested System Mode manager +surface remains debug-only, the first production signing identity does not exist, the maintained +official stable forward-port remains PR6/PR7 work, and only the exact target below is characterized. + +The machine-readable companion is +[`compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json`](../../compatibility/records/2026-08-01-mumu-1.4.46-pr5a-pr5b.json). + +## Exact target and recovery boundary + +| Field | Observed value | +|---|---| +| Host player | Chinese MuMuPlayer for macOS 1.4.46 at `/Applications/MuMuPlayer.app` | +| Existing instance | VM index 0, `我的安卓`; no new MuMu instance or application clone was created | +| ADB | `127.0.0.1:16384` for the final run; the player can rotate this port, so host checks resolved it from `mumutool info 0` | +| Guest | Android 12/API 32, `arm64-v8a`, 4 KiB pages, fingerprint digest `d3eec945343c1dc4b4bf2ea4a6caee01299b405bc66648b846a28ff9e8e3e778` | +| Persistent system | ext4 `/dev/block/sda1` mounted at `/system`; no dynamic partitions, dm-verity layer, shared-block feature, or overlay was observed | +| Init/runtime | proven `/system/etc/init` import; dedicated `/system/etc/init/magisk.rc`; `/debug_ramdisk` runtime | +| SELinux | enabled but permissive; live policy plus `/vendor/etc/selinux/precompiled_sepolicy`. This is not enforcing-SELinux evidence | +| Vendor root | `vmRootEnable: false` before, during, and after final restoration; root evidence came from Kitsune | +| Backup | stopped-VM copies of `system.qcow2`, `data.qcow2`, `config.json`, and `setting/vm.json` | +| Recovery exercise | marker divergence, stop, staged restore, QCOW integrity checks, byte/image comparison, restart, marker absence; repeated after qualification | + +The verified backup digests were: + +| Object | SHA-256 | +|---|---| +| `system.qcow2` | `f306b4da4c32663f0a5c07e704917aff61c7d7c5806ecb50da9a41bda7884c0a` | +| `data.qcow2` | `698adbcdd2e82a21f104ac1866929ff2ca40d254013a34bc46d1c6c28b908d95` | +| `config.json` | `03a6705bfb80304c5f0422d4acf86efe79133f3c7502a4713a6c41254f21fb9a` | +| `setting/vm.json` | `c65c53a0900a4e4d6f2cea15e1c685b3a92b22d105473f2bae545f531e64312c` | +| Recovery tuple | `c62ca78e6326d0fdf8920e1c286705883c40ca161db4b2a622bd20c725654db2` | + +The final physical staging attempt deliberately preserved the live names when it encountered host +ENOSPC. The partial hidden staging file was removed, the same-volume APFS copy-on-write recovery +stage was created, both staged and published QCOWs passed `qemu-img check`, and both comparisons +reported `Images are identical`. The two JSON files passed `cmp`. The external backup was deleted +only after the restored guest booted and its released baseline was verified. + +## Artifact identity + +| Artifact | Version/source | APK SHA-256 | Certificate SHA-256 | Purpose | +|---|---|---|---|---| +| Published comparison APK | `31.0-kitsune`, released lineage after `25fa2159` | `fac319d2de262fcfff1684e13e1a5c61c486d2a773a7a8ffcfdbfe6f763a7fd4` | historical public test certificate `a9342f305e5d7ecc0245f86c931226267389358c48139f5d7ed6a80cd4329629` | Same-target baseline | +| Merged PR #26 debug | `f6beadd7-kitsune` | `0cbef1b6db219e8f5867830863aee3ba2a88ed7a9cbf68f5cd6ea520a6135db4` | Android debug `29b04c032802179a86374c8a13dba9ddbe8d9303f4909ac03766cb77de17c027` | Hardened pre-transaction comparison | +| Transaction candidate | `4a216671-kitsune` | `6851aabb0f707e75d2578d493d2d48860624cef62b952cf03ef26c9a58d25398` | Android debug | Full live stress before the final staging-cleanup correction | +| Exact final debug | `c86bdce4-kitsune`, `c86bdce439cba1b0317073a0a50dfda95dfde688` | `b84171fb47e54cd85248049a792cb1e12d2eabd42ccd042bb6049897c2e0b21a` | Android debug | Final install/upgrade, cold boot, receipt, uninstall, and restore | +| Exact final release build | same source, native release identity | `57f7af115a65c5972951835cf911305b75e560470a73bd5c254367e139616995` | disposable qualification certificate `ffa64fc05fdc00d29b910b28f0cf9429a2cc7b3e85327f75ecf240658f589b5f` | Build/artifact gate only; release UI still excludes System Mode | + +The final debug stager recorded full source commit +`c86bdce439cba1b0317073a0a50dfda95dfde688`, audited upstream base +`154121f3dd92e67a3d8e3f518684932c0f9783e6`, and the exact APK digest above. The supported doctor +report SHA-256 was `cf547e6361146a57e766c6102c52af567fe425b58741e006ce7ceff15abd1a12`. + +## Same-target lifecycle results + +Each comparison started from the byte-verified external baseline rather than a second MuMu +instance. The released comparison ran first; the external restore separated it from the hardened +and transactional runs. + +| Check | Released comparison | Current PR feature set | Exact final `c86bdce4` | +|---|---|---|---| +| System Mode install | Passed | Passed | Passed as an upgrade from the prior transactional candidate | +| True cold boots / player stop-start | Three passed | Three passed on `4a216671` | One passed and transitioned to `BOOT_VERIFIED` | +| Root | `uid=0`, `u:r:magisk:s0` | Passed with vendor root disabled | `uid=0`, `u:r:magisk:s0` | +| Root policy | Basic root passed | Shell policy deny, UI allow, and post-boot persistence passed | Root passed; the deny/allow sequence was not repeated after the cleanup-only amendment | +| Minimal module | Mounted marker passed, then removal passed | Mounted marker passed, then removal passed | Not repeated after the cleanup-only amendment | +| Upgrade/reinstall | Passed | Legacy-to-manifest upgrade and transactional reinstall passed | Parent-to-final transactional upgrade passed | +| Injected failure | Not available | Live process death after STAGED restored the exact prior receipt/state and left no payload residue | Host regression covers the final cleanup ordering | +| Uninstall | Root payload removed, but the manager package remained | Manager, root, and exact manifest-owned paths removed | Passed; state reached `UNINSTALLED`, manager and `su` disappeared, and owned system/init/manifest paths were absent | +| External restore | Passed | Passed | Passed; final guest returned to released `31.0-kitsune` legacy System Mode with no PR5 manifest | + +The final manifest used stable install ID `58e942f3-ac2b-43f4-9327-a206d7af6223`, transaction ID +`f07ed1ef-3f6e-4f6a-b0b3-628da354c096`, adapter `mumu-1.4.46`, init path +`/system/etc/init/magisk.rc`, runtime `/debug_ramdisk`, and strategy +`live+precompiled:/vendor/etc/selinux/precompiled_sepolicy`. After boot, the system and data copies +of `install-manifest.json` were byte-identical with SHA-256 +`623e737aef606325b48befe9737fde38961681b7d133f2c7fa951cb39ad0c791`. + +## PR5B transaction result + +The current line now has one shared complete System Mode installer used by the app and explicitly +selected recovery/addon paths. It provides: + +- an authorization bound to a supported doctor report, live fingerprint/API/ABI, named adapter, + explicit external-backup location/digest/restore command, init import, persistence evidence, full + source commit, upstream base, and APK digest; +- a persistent state receipt and versioned JSON manifest under + `/data/adb/kitsune/system-mode`, including owned paths, originals, payload hashes and metadata, + target strategies, backup identity, and an ordered journal; +- deterministic recovery for `PREFLIGHTED`, `STAGED`, `COMMITTED`, `BOOT_VERIFIED`, + `ROLLBACK_REQUIRED`, `ROLLING_BACK`, `FAILED`, and `UNINSTALLED` rather than filename inference; +- write probing, file/tree and parent-directory fsync, same-directory atomic publication, exact + pre-upgrade validation, reverse rollback, boot-ID verification, and a tmpfs-relocated boot + verifier that survives remounting the script's source filesystem; +- exact hash-aware uninstall and original restoration without wildcard deletion; paths outside the + ownership manifest, including modules and unrelated `/data/adb` data, are preserved; +- layout-aware `/sbin` or `/debug_ramdisk` selection, proven init directory selection, and the + actual disabled/live/precompiled/monolithic/split policy strategy recorded in the receipt; and +- explicit recovery System Mode routing without filename-substring activation or ordinary + boot-image discovery. + +Host tests inject ENOSPC, EROFS, short writes, file-fsync, parent-fsync, rename, process death, and +reboot-style interruption at durable publication boundaries. They also exercise exact receipt/path +validation, changed live target rejection, upgrade metadata restoration, absent optional trees, +reverse rollback, interrupted uninstall retry, hash mismatch refusal, and unowned-file protection. +The live MuMu injection killed the installer after STAGED; automatic recovery restored the prior +`BOOT_VERIFIED` installation and exact digests. Every fault at every mutation boundary was not +repeated live, so that broader commercial-emulator stress remains a pre-release matrix item. + +## Concrete defects found and fixed + +| Before | After / proof | +|---|---| +| Released complete uninstall removed root but left the manager installed. | Transactional uninstall removes the manager before tearing down root; both candidate and exact-final live runs left the package absent. | +| PR #26 had no persistent ownership manifest and could not distinguish an interrupted process from an installed system. | State, ownership, originals, journal, two manifest copies, and boot proof persisted; live staged process death recovered deterministically. | +| Recovery filename substrings could activate System Mode and recovery still had route-specific behavior. | Explicit `SYSTEMMODE` selection reaches the shared complete transaction; substring activation is gone. | +| Runtime/init/policy behavior relied on legacy layout assumptions and a successful write was treated as proof. | The authorization and receipt bind the selected writable directory, imported RC, runtime tmpfs, and actual policy source/strategy; MuMu booted from `/debug_ramdisk`. | +| The boot verifier could block while remounting the filesystem that contained its own script, and recovery used a BusyBox copy with a basename that broke applet dispatch. | Verification relocates itself and a correctly named `busybox` to boot tmpfs; live boots reached `BOOT_VERIFIED` and rollback completed. | +| Enforcing-style SELinux context ownership checks rejected MuMu's permissive/unlabeled persistence behavior. | Context is strict only when SELinux enforces; MuMu's permissive boot verified while enforcing targets retain exact checks. | +| A completed `4a216671` transaction left `.install-manifest.json.new` beside the durable receipt. | `c86bdce4` cleans staging after both manifest publications and before exposing COMMITTED; focused regression plus live before/after showed no staging names before or after boot. | +| MuMu also exposes an `emulator-5554` alias, so a temporary AVD using an assumed/default transport could attach to the wrong guest. | `scripts/avd_test.sh` waits for the old transport to disappear, records a new boot ID, and verifies expected AVD identity; its regression rejects an occupied/stale transport. | + +## Android 6 and Android 16 ordinary-install stress + +Temporary ARM64 AVDs were used only for normal patched-ramdisk Magisk compatibility; MuMu was the +only Direct-System target. Both AVDs ran the debug and disposable-release artifacts built at +`4a216671`, which differs from the final source only by the System Mode staging-cleanup correction. + +| AVD | Result | +|---|---| +| Android 6/API 23 default ARM64, `kitsune-pr5-api23`, port 5564 | Debug and release patch, boot, manager setup, reboot, root, four rounds of eight concurrent `su` calls, and the security corpus passed. Stock ramdisk was byte-restored and the AVD was deleted. | +| Android 16/API 36 Google APIs ARM64, `kitsune-pr5-api36`, port 5566 | The same debug/release lifecycle and stress passed. Stock ramdisk was byte-restored and the AVD was deleted. | + +This proves ordinary Magisk support at the old and current API edges. API 23–24 System Mode remains +excluded, and immutable Android 15/16 EROFS/AVB layouts remain negative System Mode cases. + +## Final local gates and cleanup + +- 126 Python host tests passed after the staging-residue regression was added. +- `:app:testDebugUnitTest`, app/shared/stub debug lint, `bash -n`, ShellCheck 0.11.0 error severity, + and `git diff --check` passed. +- Exact all-ABI debug/release app and stub builds passed the source/native-mode, signer separation, + historical-certificate rejection, APK integrity, and 16 KiB ELF/ZIP-alignment contract. +- Final artifact hashes: debug stub + `af45fe5645f469a42c0c86e5393682c12643255f00f19739ccca8933e9957c84`; release stub + `5a6532dd9ed22e8084b0f43282c1210dd3baf58a61a683ad9e96ca1a269eb1b3`. +- The temporary API 23 SDK image, both named AVDs, test corpus outputs, screenshots, doctor reports, + qualification keystores, and external MuMu backup were removed after their retained evidence was + recorded. The pre-existing API 36 SDK image was not removed. +- Final MuMu state is the restored released `31.0-kitsune:MAGISK:R` legacy System Mode baseline, + reachable at port 16384 when this record was completed, with vendor root disabled and no PR5 + transaction present. + +## Remaining non-claims + +This does not qualify other MuMu versions, Windows MuMu, LDPlayer, Nox, BlueStacks System Mode, +physical devices, enforcing SELinux on MuMu, OTA/addon survival on a real vendor update, every live +power-loss boundary, API 23–24 System Mode, a production certificate, a project update service, +stable external-Zygisk/Hide/SuList semantics, or a public release. PR6/PR7 must still port this +tested contract onto the freshly audited maintained official stable base; PR15/PR16 own broad +compatibility and release promotion. From b416c8ddd900023c8b9dd8d5569395d91bd0dc4c Mon Sep 17 00:00:00 2001 From: Jordan Ye Date: Sat, 1 Aug 2026 13:40:32 -0400 Subject: [PATCH 3/3] fix(app): stabilize Android 6 manager extraction --- DEVELOPMENT_ROADMAP.md | 11 ++++++-- app/src/main/AndroidManifest.xml | 1 - .../topjohnwu/magisk/core/su/TestHandler.kt | 23 +++++++++++++---- .../magisk/core/tasks/MagiskInstaller.kt | 25 ++++++++++++------- docs/status.md | 8 +++--- docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md | 3 ++- tests/security_lab/test_device_corpus.py | 14 +++++++++++ 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/DEVELOPMENT_ROADMAP.md b/DEVELOPMENT_ROADMAP.md index 3de80eb5d..0743cda4b 100644 --- a/DEVELOPMENT_ROADMAP.md +++ b/DEVELOPMENT_ROADMAP.md @@ -59,6 +59,13 @@ PR7 must port rather than reinvent. hardened debug/release artifacts; ordinary root/backend alignment passed and the original payload was restored. Legacy Android 6 ADB-PTY CRLF is normalized at both readiness and concurrent-`su` result boundaries, with focused regressions for each parser. +- The PR5A/PR5B hosted gate then exposed an inherited Android 6 x86_64 manager-extraction defect: + release setup failed twice after a complete debug lifecycle. Diagnostic run + [30710846323](https://github.com/Jordan231111/KitsuneMagisk/actions/runs/30710846323) proved that + root was valid and extraction failed before `fix_env`. The branch ports upstream `cf12087e2` by + removing `android:multiArch` and hidden `secondaryNativeLibraryDir` reflection, then loading + Kitsune's 32-bit companion through the APK classloader. A focused contract and the hosted API 23 + debug/release lane retain the before/after regression. - Exact two-commit hosted reruns caught two harness-only edge cases before merge. [Run 30696816242](https://github.com/Jordan231111/KitsuneMagisk/actions/runs/30696816242) showed that Android 6 retained a carriage return in otherwise-correct PackageManager and Magisk readiness @@ -478,7 +485,7 @@ Start with current official code, but reuse current Kitsune behavior and fixture | Latest Kitsune CI and local AVD evidence | [PR #26 run 30697832776](https://github.com/Jordan231111/KitsuneMagisk/actions/runs/30697832776) passed source, build/JVM, API 23/29/35, and aggregate product gates at pre-final two-commit head `fc10d9242`, including the Android 6 readiness fix. Its paired security run exposed only volatile global RustSec metadata and led to the final semantic-comparison fix. Pre-final artifact head `1cac2135e` passed local official ARM64 API 34/35/36 debug and release patched-ramdisk boots, manager setup/reboot/self-test/root, 32 concurrent `su` calls per artifact, 137-case parser/policy/signing corpus per artifact, byte restoration, and AVD deletion. Final code commit `530f2a3f8` adds no app/native change beyond that product-tested content; it passed the 99-test local host suite and a fresh semantic RustSec check. The earlier [PR4A lab record](docs/system-mode/avd-lab-2026-07-22.md) retains immutable API 35 16 KiB/API 36 negative evidence. | Ordinary Magisk integration is evidenced on hosted x86_64, local ARM64 Android 14–16, and the exact BlueStacks comparison target. The exact two-commit hosted run after the semantic RustSec fix is the mandatory merge record; none of these normal-install lanes substitutes for writable System Mode qualification. | | Local build | The pinned ONDK is installed; canonical debug/minified-release builds and Gradle debug native links pass for ARM64, ARM32, x86_64, and x86 on this Mac. Final testing found that Gradle's `NDK_DEBUG=1` omitted section GC and pulled dead ARMv7 unwind code; `Application.mk` now makes the canonical and Gradle link contracts explicit and the formerly failing ARMv7 path passes. | Preserve the exact toolchain/bootstrap checks so another maintainer can reproduce the result. | | Local submodules | All current Kitsune submodules are initialized at their recorded gitlinks. A separate full recursive official-Magisk clone also checked out every current upstream submodule. | Recursive checkout remains a documented prerequisite; PR4B now automates reachability and pin drift. | -| Tests | PR #26's squashed local candidate passed 99 host tests, JVM tests, zero-error lint, shell/source checks, clean all-ABI debug/release builds, artifact identity/signing checks, same-instance BlueStacks backend comparisons, official Android 14–16 ARM64 lifecycles, and API 35 provider/module/HideList/hidden-manager characterization. PR5A/PR5B expands the host suite to 126 tests, passes exact all-ABI debug/release artifact checks, repeats ordinary debug/release lifecycles on temporary ARM64 API 23 and API 36 AVDs, and completes the exact MuMu evidence described below. | The writable current-line oracle now exists, but it remains debug-only and exact-version experimental. SuList, early-mount, broad module compatibility, enforcing/multi-target System Mode, production identity, and the maintained-base port remain release blockers. Heavy stress stays local; retained CI regressions are bounded and high-yield. | +| Tests | PR #26's squashed local candidate passed 99 host tests, JVM tests, zero-error lint, shell/source checks, clean all-ABI debug/release builds, artifact identity/signing checks, same-instance BlueStacks backend comparisons, official Android 14–16 ARM64 lifecycles, and API 35 provider/module/HideList/hidden-manager characterization. PR5A/PR5B expands the host suite to 127 tests, passes exact all-ABI debug/release artifact checks, repeats ordinary debug/release lifecycles on temporary ARM64 API 23 and API 36 AVDs, and completes the exact MuMu evidence described below. | The writable current-line oracle now exists, but it remains debug-only and exact-version experimental. SuList, early-mount, broad module compatibility, enforcing/multi-target System Mode, production identity, and the maintained-base port remain release blockers. Heavy stress stays local; retained CI regressions are bounded and high-yield. | | Primary product feature | System Mode originated in `05289fb5` and now spans manager UI, shell/recovery installation, native tmpfs setup, policy, init, persistence, and uninstall | It must be treated as the branch-selection and release-qualification gate, not an optional later experiment | | System Mode test coverage | Host tests exercise authorization, target/source binding, manifest/state parsing, debug/release gating, private mount behavior, fsync/atomic fault classes, reverse recovery, boot verification, upgrade, and exact uninstall. Live BlueStacks still proves read-only refusal. MuMuPlayer 1.4.46 now has the same-instance released/current comparison, three candidate cold boots, player cycles, root policy, a minimal module, reinstall, staged-process-death rollback, exact-final upgrade/boot/uninstall, and two byte-verified external restores. | Preserve MuMu as an exact experimental record, not a brand-level support claim. Use this current-line transaction and its failures as the PR7 oracle on the maintained official base; PR15 must repeat the full exact-artifact matrix across every advertised target. | | Official reusable emulator logic | Magisk v30.7 `scripts/live_setup.sh` supports API 23–36 and handles legacy `/sbin` versus modern `/debug_ramdisk` runtime setup | The v30.7 port can replace several old custom primitives; it is a bounded vertical slice, not a from-scratch Magisk rewrite | @@ -2011,7 +2018,7 @@ to work” is not an exit. receipt, ownership/original inventories, JSON manifest, ordered journal, target/source/artifact and external-recovery binding, layout-aware runtime/init/policy selection, durable atomic publication, boot-ID verification, deterministic interrupted-state recovery, and exact hash-aware uninstall. -The 126-test host suite covers the complete required fault classes; live MuMu proves STAGED process +The 127-test host suite covers the complete required fault classes; live MuMu proves STAGED process death recovery and the manifest-owned lifecycle. Every live power-cut boundary and real OTA/addon cycle remain PR15/PR16 qualification work, not hidden claims of this exit. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51c203cd3..1e7b6df78 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ diff --git a/app/src/main/java/com/topjohnwu/magisk/core/su/TestHandler.kt b/app/src/main/java/com/topjohnwu/magisk/core/su/TestHandler.kt index a52ff2727..f68be4856 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/su/TestHandler.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/su/TestHandler.kt @@ -7,7 +7,6 @@ import com.topjohnwu.magisk.core.di.ServiceLocator import com.topjohnwu.magisk.core.tasks.MagiskInstaller import com.topjohnwu.magisk.core.utils.RootUtils import com.topjohnwu.superuser.Shell -import com.topjohnwu.superuser.internal.NOPList import kotlinx.coroutines.runBlocking object TestHandler { @@ -16,10 +15,24 @@ object TestHandler { val r = Bundle() fun setup(): Boolean { - val nop = NOPList.getInstance() - return runBlocking { - MagiskInstaller.Emulator(nop, nop).exec() + val console = mutableListOf() + val logs = mutableListOf() + val success = runBlocking { + MagiskInstaller.Emulator(console, logs).exec() } + if (!success) { + val output = (console.asSequence() + logs.asSequence()) + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") + .takeLast(4096) + r.putString( + "reason", + "setup failed (root=${Shell.getShell().isRoot})" + + if (output.isEmpty()) " without installer output" else "\n$output" + ) + } + return success } fun test(): Boolean { @@ -62,4 +75,4 @@ object TestHandler { r.putBoolean("result", b) return r } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt index b235e3437..d0e6f1a9d 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt @@ -1,6 +1,7 @@ package com.topjohnwu.magisk.core.tasks import android.net.Uri +import android.os.Process import android.system.ErrnoException import android.system.Os import android.system.OsConstants @@ -123,21 +124,26 @@ abstract class MagiskInstallImpl protected constructor( zf.close() } else { val info = context.applicationInfo - var libs = File(info.nativeLibraryDir).listFiles { _, name -> + val libs = File(info.nativeLibraryDir).listFiles { _, name -> name.startsWith("lib") && name.endsWith(".so") } ?: emptyArray() - // Also symlink magisk32 on non 64-bit only 64-bit devices - val lib32 = info.javaClass.getDeclaredField("secondaryNativeLibraryDir") - .get(info) as String? - if (lib32 != null) { - libs += File(lib32, "libmagisk32.so") - } - for (lib in libs) { val name = lib.name.substring(3, lib.name.length - 3) Os.symlink(lib.path, "$installDir/$name") } + + // Do not depend on the hidden secondaryNativeLibraryDir field. + // Android's multi-arch extraction is unstable across package + // replacement on legacy releases; read the one 32-bit applet + // directly from the installed APK, matching current upstream. + val abi32 = Const.CPU_ABI_32 + if (Process.is64Bit() && abi32 != null) { + val name = "lib/$abi32/libmagisk32.so" + javaClass.classLoader!!.getResourceAsStream(name)?.use { + it.writeTo(File(installDir, "magisk32")) + } + } } // Extract scripts @@ -164,7 +170,8 @@ abstract class MagiskInstallImpl protected constructor( context.assets.open(name).writeTo(dest) } } catch (e: Exception) { - console.add("! Unable to extract files") + console.add("! Unable to extract files: ${e.javaClass.simpleName}: ${e.message}") + logs.add(e.stackTraceToString()) Timber.e(e) return false } diff --git a/docs/status.md b/docs/status.md index d81723dfc..5acbf5d7a 100644 --- a/docs/status.md +++ b/docs/status.md @@ -7,9 +7,11 @@ but the resumed project has not shipped its first production release. The inheri a fork compatibility and Android upgrade-ordering value; it does not mean this branch is newer than official Magisk. -The PR5A/PR5B work is based on merged `kitsune` at `f6beadd7`; its exact implementation head is -`c86bdce4`. Official comparison points rechecked on 2026-08-01 are Magisk v30.7 (`e8a58776`) and -the observed official `master` tip `fd0cb66b`. +The PR5A/PR5B work is based on merged `kitsune` at `f6beadd7`; the durable System Mode code and +exact MuMu-tested artifact are at `c86bdce4`. A later branch-only manager compatibility follow-up +ports upstream's removal of the legacy multi-arch extraction path after the hosted Android 6 lane +reproduced its failure. Official comparison points rechecked on 2026-08-01 are Magisk v30.7 +(`e8a58776`) and the observed official `master` tip `fd0cb66b`. ## What currently works diff --git a/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md b/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md index bd7605a9f..d8f65ac83 100644 --- a/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md +++ b/docs/system-mode/mumu-pr5a-pr5b-2026-08-01.md @@ -122,6 +122,7 @@ repeated live, so that broader commercial-emulator stress remains a pre-release | Enforcing-style SELinux context ownership checks rejected MuMu's permissive/unlabeled persistence behavior. | Context is strict only when SELinux enforces; MuMu's permissive boot verified while enforcing targets retain exact checks. | | A completed `4a216671` transaction left `.install-manifest.json.new` beside the durable receipt. | `c86bdce4` cleans staging after both manifest publications and before exposing COMMITTED; focused regression plus live before/after showed no staging names before or after boot. | | MuMu also exposes an `emulator-5554` alias, so a temporary AVD using an assumed/default transport could attach to the wrong guest. | `scripts/avd_test.sh` waits for the old transport to disappear, records a new boot ID, and verifies expected AVD identity; its regression rejects an occupied/stale transport. | +| Hosted API 23 x86_64 failed release manager setup twice after a complete debug lifecycle; diagnostic run [`30710846323`](https://github.com/Jordan231111/KitsuneMagisk/actions/runs/30710846323) proved root was valid and failure occurred while extracting the manager payload, before `fix_env`. | The branch ports upstream `cf12087e2` with Kitsune's `libmagisk32.so` name: `android:multiArch` and reflection on hidden `secondaryNativeLibraryDir` are removed, and the 32-bit companion is read through the APK classloader. A focused source contract and the required API 23 debug/release hosted lane guard the behavior. | ## Android 6 and Android 16 ordinary-install stress @@ -139,7 +140,7 @@ excluded, and immutable Android 15/16 EROFS/AVB layouts remain negative System M ## Final local gates and cleanup -- 126 Python host tests passed after the staging-residue regression was added. +- 127 Python host tests passed after the staging-residue and manager-extraction regressions were added. - `:app:testDebugUnitTest`, app/shared/stub debug lint, `bash -n`, ShellCheck 0.11.0 error severity, and `git diff --check` passed. - Exact all-ABI debug/release app and stub builds passed the source/native-mode, signer separation, diff --git a/tests/security_lab/test_device_corpus.py b/tests/security_lab/test_device_corpus.py index 273a29934..6fc0fb276 100644 --- a/tests/security_lab/test_device_corpus.py +++ b/tests/security_lab/test_device_corpus.py @@ -183,6 +183,20 @@ def test_normal_avd_waits_for_a_new_owned_boot(self) -> None: self.assertIn('[ "$active_avd" = "$avd_name" ]', readiness) self.assertIn('[ "$boot_id" != "$emu_boot_id" ]', readiness) + def test_manager_extraction_avoids_hidden_multiarch_state(self) -> None: + source = ( + ROOT + / "app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt" + ).read_text(encoding="utf-8") + manifest = (ROOT / "app/src/main/AndroidManifest.xml").read_text( + encoding="utf-8" + ) + self.assertNotIn('getDeclaredField("secondaryNativeLibraryDir")', source) + self.assertNotIn('android:multiArch="true"', manifest) + self.assertIn("Process.is64Bit()", source) + self.assertIn('"lib/$abi32/libmagisk32.so"', source) + self.assertIn("getResourceAsStream(name)", source) + if __name__ == "__main__": unittest.main()