diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 4e07254..84fa925 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -26,6 +26,8 @@ TARGET = "apple-silicon-full-os" MAXIMUM_PASSWORD_BYTES = 1_024 MACHINE_OWNER_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,255}$") +INSTALLER_TITLE_PATTERN = re.compile(r"^[A-Za-z0-9 ._()-]{1,64}$") +VOLUME_GROUP_PATTERN = re.compile(r"^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$") PARTITION_PATTERN = re.compile(r"^disk[0-9]+s[0-9]+$") READBACK_CHUNK_BYTES = 1024 * 1024 @@ -34,6 +36,357 @@ class AsahiAdapterError(RuntimeError): pass +# The Recovery setup that the stub's "Finish Installation" app opens in +# Terminal, replacing asahi-installer's step2.sh: the installer's own name, one +# password prompt and one confirmation. bputil and bless take the owner and +# password as arguments; kmutil has no credential options, so the script +# answers its prompts on a hidden terminal. +STEP2_SCRIPT = """#!/bin/sh +# SPDX-License-Identifier: MIT +# ##TITLE##, second step. Runs in the new install's own recoveryOS. + +set -e + +VGID="##VGID##" +PREBOOT="##PREBOOT##" +OWNER="##OWNER##" + +self="$0" +cd "${self%%step2.sh}" +system_dir="$(cd ../../../; pwd)" +os_name="${system_dir##*/}" + +BOLD="$(printf '\\033[1m')" +RST="$(printf '\\033[m')" + +LOGS="/tmp/bp.txt /tmp/bless.log /tmp/bputil.log /tmp/bputil.status +/tmp/kmutil.log /tmp/kmutil.status /tmp/kmutil.pid /tmp/kmutil.done" +spinner_pid="" + +# Stop the kmutil whose ID is in file $1, and wait until it is gone. A process +# that has since reused the ID is left alone. +stop_pid_in() { + [ -s "$1" ] || return 0 + pid="$(cat "$1")" + if command -v ps >/dev/null 2>&1; then + case "$(ps -p "$pid" -o args= 2>/dev/null)" in + *kmutil*configure-boot*) ;; + *) return 0 ;; + esac + fi + kill "$pid" 2>/dev/null || return 0 + waited=0 + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -eq 20 ]; then + kill -9 "$pid" 2>/dev/null || true + fi + sleep 0.1 + waited=$((waited + 1)) + done +} + +# On any exit: stop what this script started, restore the terminal, and +# forget the password and the logs. +cleanup() { + if [ -n "$spinner_pid" ]; then + kill "$spinner_pid" 2>/dev/null || true + spinner_pid="" + fi + stop_pid_in /tmp/kmutil.pid + stty echo 2>/dev/null || true + PASSWORD="" + rm -f $LOGS +} +trap cleanup EXIT +# bputil runs in the foreground, so Ctrl-C reaches it. kmutil runs under +# script on a hidden terminal; cleanup stops it through its PID file. +trap 'echo; echo "Stopped. Run this again to finish setting up $os_name."; exit 130' INT TERM HUP + +# Spin after the current line until file $1 exists. After $2 seconds, stop +# the process whose ID is in file $3, if one is given. Runs in the background. +spin_until() { + spun=0 + printf ' ' + while [ ! -e "$1" ]; do + if [ "$spun" -eq $(($2 * 4)) ] && [ -n "$3" ]; then + stop_pid_in "$3" + fi + case $((spun % 4)) in + 0) c='|' ;; + 1) c='/' ;; + 2) c='-' ;; + *) c='\\' ;; + esac + printf '\\b%s' "$c" + sleep 0.25 + spun=$((spun + 1)) + done + printf '\\b ' +} + +# Run the rest of the line with a spinner after the current line, which the +# caller ends. Its exit status lands in file $1; after $2 seconds the process +# in file $3 is stopped. +run_with_spinner() { + status_file=$1 + deadline=$2 + pid_file=$3 + shift 3 + rm -f "$status_file" + spin_until "$status_file" "$deadline" "$pid_file" & + spinner_pid=$! + status=0 + "$@" || status=$? + echo "$status" >"$status_file" + wait "$spinner_pid" 2>/dev/null || true + spinner_pid="" + return "$status" +} + +bputil_now() { + bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD" >/tmp/bputil.log 2>&1 +} + +read_password() { + printf "Password for %s: " "$OWNER" + stty -echo + IFS= read -r PASSWORD + stty echo + echo +} + +printf '\\033[2J\\033[H' +echo "${BOLD}##TITLE##${RST}" +echo + +bputil -d -v "$VGID" >/tmp/bp.txt + +if [ -z "$OWNER" ]; then + printf "Your macOS user name: " + read -r OWNER +fi + +# Whether the startup disk is this Omarchy: bless names a volume of its group. +omarchy_is_startup() { + boot="$(bless --getBoot 2>/dev/null)" || return 1 + [ -n "$boot" ] || return 1 + diskutil info "$boot" 2>/dev/null | grep -Eiq "^ *APFS Volume Group: +$VGID *\\$" && return 0 + [ "$boot" = "$(diskutil info "$system_dir" 2>/dev/null | sed -n 's/^ *Device Node: *//p')" ] +} + +if ! grep -q ': Paired' /tmp/bp.txt; then + if omarchy_is_startup; then + echo "This step needs $os_name's own Recovery. $os_name is already the" + echo "startup disk, so the next start opens it." + else + echo "This step needs $os_name's own Recovery. Making $os_name the startup" + echo "disk so that the next start opens it." + echo + # bless takes the known owner and the password on stdin. It exits 0 + # even when it rejects the password, so check the startup disk instead. + # After three failures, let bless ask on its own. + tries=0 + while :; do + read_password + printf '%s\\n' "$PASSWORD" | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1 || true + if omarchy_is_startup; then + break + fi + tries=$((tries + 1)) + if [ "$tries" -ge 3 ]; then + echo "macOS asks itself now. Type your user name and password." + until bless --setBoot --mount "$system_dir" && omarchy_is_startup; do + echo "That didn't work. Press Enter to try again." + read -r _ + done + break + fi + echo "That password didn't work for $OWNER. Try again." + echo + done + PASSWORD="" + fi + echo + echo "Press Enter to shut down. Then hold the power button until you see" + echo "'Loading startup options', choose $os_name, and log in." + read -r _ + shutdown -h now + exit 1 +fi + +if ! grep -q 'one true recoveryOS' /tmp/bp.txt; then + echo "The power button was released too early to finish setting up." + echo + echo "Press Enter to shut down. Then hold the power button, without letting" + echo "go, until you see 'Loading startup options', and choose $os_name." + read -r _ + shutdown -h now + exit 1 +fi + +echo "This lets $os_name start Linux by lowering the security level of" +echo "$os_name only. ${BOLD}macOS keeps Full Security.${RST}" +echo +# Ask before anything changes: bputil lowers the security level at once. +printf "Are you sure you want to do this? (y or n) " +read -r answer +case "$answer" in + y|Y|yes|Yes|YES) ;; + *) + echo "Nothing was changed. Run this again when you're ready." + exit 1 + ;; +esac +echo + +tries=0 +while :; do + read_password + printf "Updating %s's security settings..." "$os_name" + if run_with_spinner /tmp/bputil.status 0 "" bputil_now; then + echo + break + fi + # Nothing was updated: replace that line. bputil's log opens with its own + # disclaimer, so don't show it here. + printf '\\r\\033[K' + tries=$((tries + 1)) + if [ "$tries" -ge 3 ]; then + echo "macOS asks itself now. Type your user name and password." + while ! bputil -nc -v "$VGID"; do + echo "That didn't work. Press Enter to try again." + read -r _ + done + # The typed password was rejected: never offer it to kmutil. + PASSWORD="" + break + fi + echo "That password didn't work for $OWNER. Try again." + echo +done + +if [ -e "/System/Volumes/iSCPreboot/$VGID/boot" ]; then + # An external volume: kmutil looks for AdminUserRecoveryInfo.plist in the + # wrong place. + diskutil mount "$PREBOOT" + preboot="$(diskutil info "$PREBOOT" | grep "Mount Point" | sed 's, *Mount Point: *,,')" + cp -R "$preboot/$VGID/var" "/System/Volumes/iSCPreboot/$VGID/" +fi + +printf "Installing Omarchy's boot loader..." +# kmutil asks "are you sure" on stdin, then reads a user name and password +# from its terminal, discarding anything typed ahead. So run it on a hidden +# terminal (script) and type each answer once its prompt appears. The +# password is typed with echo off, so the log never holds it. A small +# wrapper records kmutil's own process ID, so that if kmutil fails or is +# still running after a minute, it is stopped and the owner answers it. +kmutil_log=/tmp/kmutil.log +kmutil_status=/tmp/kmutil.status +kmutil_wait_for() { + tries=0 + while ! grep -q "$1" "$kmutil_log" 2>/dev/null; do + [ ! -e /tmp/kmutil.done ] && [ "$tries" -lt 600 ] || return 1 + sleep 0.1 + tries=$((tries + 1)) + done +} +kmutil_answers() { + kmutil_wait_for 'enter y or n' && printf 'y\\n' && + kmutil_wait_for 'Username:' && printf '%s\\n' "$OWNER" && + kmutil_wait_for 'Password:' && printf '%s\\n' "$PASSWORD" + # Keep kmutil's input open until it exits. + while [ ! -e /tmp/kmutil.done ]; do + sleep 0.1 + done +} +hidden_kmutil() { + rm -f /tmp/kmutil.done + kmutil_answers 2>/dev/null | { + status=0 + script -q -t 0 "$kmutil_log" \\ + /bin/sh -c 'echo $$ >/tmp/kmutil.pid; exec kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$1"' \\ + sh "$system_dir" >/dev/null 2>&1 || status=$? + # kmutil must not outlive script: stop it if it did, then forget its ID. + stop_pid_in /tmp/kmutil.pid + rm -f /tmp/kmutil.pid + touch /tmp/kmutil.done + exit "$status" + } +} +kmutil_ok=no +if [ -n "$PASSWORD" ] && command -v script >/dev/null 2>&1; then + rm -f "$kmutil_log" /tmp/kmutil.pid + if run_with_spinner "$kmutil_status" 60 /tmp/kmutil.pid hidden_kmutil; then + kmutil_ok=yes + fi + echo + stop_pid_in /tmp/kmutil.pid +fi +PASSWORD="" +if [ "$kmutil_ok" != yes ]; then + echo + echo "macOS asks once more. Type y, then your user name and password." + while ! kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$system_dir"; do + echo "That didn't work. Press Enter to try again." + read -r _ + done +fi + +mount -u -w "$system_dir" +if [ -e "$system_dir/.IAPhysicalMedia" ]; then + mv "$system_dir/.IAPhysicalMedia" "$system_dir/IAPhysicalMedia-disabled.plist" +fi +if [ -e "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" ]; then + mv -f "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" \\ + "$system_dir/System/Library/CoreServices/SystemVersion.plist" +fi +cleanup + +echo +echo "${BOLD}Done.${RST} Press Enter to restart into $os_name." +read -r _ +reboot +""" + + +def _write_step2(installer): + owner = os.environ.get("OMARCHY_MACHINE_OWNER", "") + if owner and MACHINE_OWNER_PATTERN.fullmatch(owner) is None: + raise AsahiAdapterError("machine owner name is invalid for the Recovery setup") + # The app passes its configured name (Packaging/identity.conf). + title = os.environ.get("OMARCHY_INSTALLER_NAME") or ( + os.environ.get("DISTRO", "Omarchy") + " installer" + ) + if INSTALLER_TITLE_PATTERN.fullmatch(title) is None: + raise AsahiAdapterError("installer name is invalid for the Recovery setup") + for name, value in (("volume group", installer.osi.vgid), ("Preboot volume group", installer.osi.preboot_vgid)): + if VOLUME_GROUP_PATTERN.fullmatch(value or "") is None: + raise AsahiAdapterError(f"{name} is invalid for the Recovery setup") + script = ( + STEP2_SCRIPT.replace("##TITLE##", title) + .replace("##VGID##", installer.osi.vgid) + .replace("##PREBOOT##", installer.osi.preboot_vgid) + .replace("##OWNER##", owner) + ) + with open(installer.step2_sh, "w") as fd: + fd.write(script) + os.chmod(installer.step2_sh, 0o755) + + +def stub_installer(sysinfo, dutil, osinfo): + """A StubInstaller whose Recovery setup is Omarchy's own step2.sh.""" + installer = stub.StubInstaller(sysinfo, dutil, osinfo) + install_files = installer.install_files + + def install_files_with_omarchys_step2(cur_os): + result = install_files(cur_os) + _write_step2(installer) + return result + + installer.install_files = install_files_with_omarchys_step2 + return installer + + class AsahiInPlaceRepairAdapter: """Non-partitioning adapter for one manifest-bound installed system.""" @@ -238,7 +591,7 @@ def _authorize_boot_policy(self, plan, stub_identifier): if len(matches) != 1: raise AsahiAdapterError("repair stub identity changed") target = matches[0] - existing = stub.StubInstaller( + existing = stub_installer( self.installer.sysinfo, self.installer.dutil, self.installer.osinfo, @@ -351,7 +704,7 @@ def preflight(self, plan): ipsw = self.installer.choose_ipsw( self.template.get("supported_fw"), ) - self.installer.ins = stub.StubInstaller( + self.installer.ins = stub_installer( self.installer.sysinfo, self.installer.dutil, self.installer.osinfo, diff --git a/Engine/overlay/src/omarchy_runtime.py b/Engine/overlay/src/omarchy_runtime.py index 28cb915..d449700 100644 --- a/Engine/overlay/src/omarchy_runtime.py +++ b/Engine/overlay/src/omarchy_runtime.py @@ -24,6 +24,15 @@ "OMARCHY_MACHINE_OWNER", "DISTRO", "DISTRO_DOCS", + "OMARCHY_INSTALLER_NAME", +} + +# Released apps predate OMARCHY_INSTALLER_NAME; without it the Recovery +# setup is titled from DISTRO. +OPTIONAL_INSTALL_KEYS = { + "OMARCHY_ENGINE_MODE", + "OMARCHY_ENGINE_REPAIR_MANIFEST", + "OMARCHY_INSTALLER_NAME", } @@ -70,16 +79,8 @@ def from_environment(cls, environment=None): "OMARCHY_ENGINE_REQUEST", "OMARCHY_ENGINE_IDENTITY", }, - "install": ENVIRONMENT_KEYS - - { - "OMARCHY_ENGINE_MODE", - "OMARCHY_ENGINE_REPAIR_MANIFEST", - }, - "retry-recovery-authorization": ENVIRONMENT_KEYS - - { - "OMARCHY_ENGINE_MODE", - "OMARCHY_ENGINE_REPAIR_MANIFEST", - }, + "install": ENVIRONMENT_KEYS - OPTIONAL_INSTALL_KEYS, + "retry-recovery-authorization": ENVIRONMENT_KEYS - OPTIONAL_INSTALL_KEYS, }[mode] missing = sorted(required - set(values)) if missing: diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index d1c196f..c19dc23 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -8,6 +8,10 @@ from types import SimpleNamespace import sys import tempfile +import re +import shutil +import time +import subprocess import unittest from unittest.mock import patch import zipfile @@ -46,10 +50,15 @@ def install(self, installer): class FakeStubInstaller: def __init__(self, sysinfo, dutil, osinfo): self.calls = [] + self.recovery = tempfile.TemporaryDirectory() self.osi = SimpleNamespace( - vgid="vgid-1", + vgid="11111111-2222-3333-4444-555555555555", sys_volume="System", + recovery=self.recovery.name, + preboot_vgid="66666666-7777-8888-9999-AAAAAAAAAAAA", ) + # Where the real stub writes the Recovery setup that Omarchy replaces. + self.step2_sh = os.path.join(self.recovery.name, "step2.sh") self.icon_path = dutil.stub_icon_path def load_ipsw(self, ipsw): @@ -89,9 +98,11 @@ def prepare_for_step2(self): ) from omarchy_asahi import ( # noqa: E402 + STEP2_SCRIPT, AsahiAdapterError, AsahiInPlaceRepairAdapter, AsahiStage1Adapter, + stub_installer, ) @@ -201,7 +212,7 @@ def test_free_extent_runs_exact_upstream_stage_one_primitives(self): target_evidence["partition_identifier"], "disk0s4", ) - self.assertEqual(installed_evidence["apfs_vgid"], "vgid-1") + self.assertEqual(installed_evidence["apfs_vgid"], "11111111-2222-3333-4444-555555555555") self.assertEqual(installed_evidence["efi_partition"], "efi-uuid") self.assertEqual( installed_evidence["startup_volume_icon"], @@ -811,5 +822,416 @@ def check_cur_os(self): self.check_cur_os_calls += 1 +# A kmutil that prints each prompt before reading its answer, and echoes what +# a terminal would show: the confirmation and the user name, but never the +# password, which a real terminal reads with echo off. +PROMPTING_KMUTIL = """printf 'Are you sure you want to do this? (enter y or n) '; IFS= read -r a; echo "$a" +echo 'updating local machine policy...'; printf 'Username: '; IFS= read -r u; echo "$u" +printf 'Password: '; IFS= read -r p; echo +printf '%s|%s|%s' "$a" "$u" "$p" >"$PIPED" +[ "$a|$u|$p" = "y|scott|$EXPECTED_PASSWORD" ]""" + +# Files step2.sh may leave in /tmp while it runs; none may outlive it. +STEP2_LOGS = ("bp.txt", "bless.log", "bputil.log", "bputil.status", "kmutil.log", + "kmutil.status", "kmutil.pid", "kmutil.done") + + +class Step2ScriptTests(unittest.TestCase): + """The Recovery setup: the installer's own name and one password prompt.""" + + title = "Probe Installer" + # How macOS and its recoveryOS run step2.sh's #!/bin/sh: bash in POSIX + # mode. (Linux's /bin/sh is often dash, which the class below covers.) + shell = [shutil.which("bash") or "/bin/bash", "--posix"] + # The fake recoveryOS tools' interpreter. + fake_shell = "/bin/sh" + + def make(self, owner): + root = tempfile.TemporaryDirectory() + self.addCleanup(root.cleanup) + step2 = Path(root.name) / "step2.sh" + step2.write_text("asahi step2") + + class Stub: + def __init__(self, *args): + self.osi = SimpleNamespace(vgid="0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0", preboot_vgid="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809", recovery=root.name) + self.step2_sh = str(step2) + + def load_identity(self): + pass + + def collect_firmware(self, pkg): + pass + + def install_files(self, cur_os): + pass + + with patch("omarchy_asahi.stub.StubInstaller", Stub), patch.dict( + os.environ, {"OMARCHY_MACHINE_OWNER": owner, "OMARCHY_INSTALLER_NAME": self.title} + ): + installer = stub_installer("sysinfo", "dutil", "osinfo") + installer.install_files("cur-os") + return step2 + + def test_the_setup_is_branded_and_asks_for_the_password_once(self): + step2 = self.make("scott") + text = step2.read_text() + self.assertTrue(os.access(step2, os.X_OK)) + self.assertIn("${BOLD}Probe Installer${RST}", text) + self.assertNotIn("MX Mac", text) + self.assertEqual(re.findall(r"##[A-Z]+##", text), []) + self.assertIn('VGID="0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0"', text) + self.assertIn('PREBOOT="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809"', text) + self.assertIn('OWNER="scott"', text) + # One prompt, kept whole: leading and trailing spaces are part of it. + self.assertEqual(text.count("read -r PASSWORD"), 1) + self.assertIn("IFS= read -r PASSWORD", text) + self.assertIn('bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD"', text) + # kmutil runs on a hidden terminal, recording its own process ID. + self.assertIn('script -q -t 0 "$kmutil_log"', text) + self.assertIn("echo $$ >/tmp/kmutil.pid; exec kmutil configure-boot -c boot.bin --raw --entry-point 2048", text) + self.assertIn('run_with_spinner "$kmutil_status" 60 /tmp/kmutil.pid hidden_kmutil', text) + self.assertEqual(text.count("Are you sure you want to do this? (y or n)"), 1) + # The recoveryOS checks stay. + self.assertIn("': Paired'", text) + self.assertIn("'one true recoveryOS'", text) + result = subprocess.run([*self.shell, "-n", str(step2)]) + self.assertEqual(result.returncode, 0) + + def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password="secret", + startup="macos"): + """step2.sh in place, with fake recoveryOS tools first on PATH.""" + step2 = self.make("scott") + root = Path(step2).parent + resources = root / "Omarchy" / "Finish Installation.app" / "Contents" / "Resources" + resources.mkdir(parents=True) + script = resources / "step2.sh" + script.write_text(step2.read_text()) + script.chmod(0o755) + bin_dir = root / "bin" + bin_dir.mkdir() + sleep = shutil.which("sleep") + fakes = { + "bputil": "\n".join(( + f"[ \"$1\" = -d ] && echo 'OS Pairing Status: {'Paired' if paired else 'Not Paired'}'" + " && echo 'OS Type: one true recoveryOS' && exit 0", + 'echo "$*" >>"$CALLS.bputil"', + # Without -u, bputil asks the owner itself: the fallback. + '[ "$4" = -u ] || exit 0', + "echo 'It should only be used to understand how the security works.'", + "echo 'Use at your own risk!'", + '[ "$7" = "$EXPECTED_PASSWORD" ] || exit 1', + f'[ "$BPUTIL_MODE" = hang ] && echo $$ >"$CALLS.bputil-pid" && exec {sleep} 30', + '[ "$BPUTIL_MODE" = fail ] && exit 1', + "exit 0", + )), + "stty": 'echo "$*" >>"$CALLS.stty"', + "mount": "exit 0", + "reboot": "exit 0", + "shutdown": "exit 0", + # script -q -t 0 log command...: the command's output goes to the + # log, which is kept for the test because step2.sh deletes it. Like + # the real script, which gives kmutil its own session on a hidden + # terminal, Ctrl-C ends script but never reaches kmutil: here + # kmutil runs in the background, where SIGINT is ignored. Its + # answers come through descriptor 3, since dash replaces a + # background job's stdin with /dev/null even after <&0. + "script": "\n".join(( + 'log=$4; shift 4', + "trap 'exit 130' INT", + 'exec 3<&0', + '"$@" <&3 3<&- >"$log" 2>&1 &', + 'wait $!; s=$?', + 'cp "$log" "$CALLS.kmutil-log"; exit $s', + )), + # Every sleep lasts 50 ms, so the one-minute deadline is twelve seconds. + "sleep": f"exec {sleep} 0.05", + # Like macOS's bless, it exits 0 whatever the password, and only + # a right one changes the startup disk. + "bless": "\n".join(( + '[ "$1" = --getBoot ] && { cat "$CALLS.boot"; exit 0; }', + 'echo "$*" >>"$CALLS.bless"', + 'case "$*" in', + '*--stdinpass) IFS= read -r pw; echo "$pw" >>"$CALLS.bless"', + ' [ "$pw" = "$EXPECTED_PASSWORD" ] && echo /dev/omarchy >"$CALLS.boot" ;;', + # Without --stdinpass, bless asks the owner itself. + '*) echo /dev/omarchy >"$CALLS.boot" ;;', + 'esac', + "exit 0", + )), + "diskutil": "\n".join(( + '[ "$1" = info ] || exit 0', + 'case "$2" in', + "/dev/omarchy|*/Omarchy) printf ' Device Node: /dev/omarchy\\n APFS Volume Group: 0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0\\n' ;;", + "*) printf ' Device Node: /dev/disk0s2\\n APFS Volume Group: MACOS-VG\\n' ;;", + 'esac', + )), + "kmutil": 'n=$(($(cat "$CALLS" 2>/dev/null || echo 0) + 1)); echo "$n" >"$CALLS"\n' + kmutil_body, + } + for name, body in fakes.items(): + (bin_dir / name).write_text(f"#!{self.fake_shell}\n" + body + "\n") + (bin_dir / name).chmod(0o755) + calls = root / "kmutil-calls" + Path(f"{calls}.boot").write_text("/dev/omarchy\n" if startup == "omarchy" else "/dev/disk0s2\n") + env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", CALLS=str(calls), + PIPED=str(root / "piped"), EXPECTED_PASSWORD=password, BPUTIL_MODE=bputil_mode) + return script, env, calls + + def run_step2(self, kmutil_body, stdin="y\nsecret\n\n", **options): + script, env, calls = self.step2_with_fakes(kmutil_body, **options) + result = subprocess.run([*self.shell, str(script)], input=stdin, env=env, + capture_output=True, text=True, timeout=60) + kmutil_calls = int(calls.read_text()) if calls.exists() else 0 + return result, kmutil_calls, calls + + def assert_nothing_left_in_tmp(self): + for name in STEP2_LOGS: + self.assertFalse(os.path.exists(f"/tmp/{name}"), name) + + def test_kmutil_is_answered_out_of_sight(self): + result, kmutil_calls, calls = self.run_step2(PROMPTING_KMUTIL) + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertEqual(Path(calls.parent, "piped").read_text(), "y|scott|secret") + self.assertNotIn("Username", out) + self.assertNotIn("type y", out) + self.assertEqual(out.count("Password for scott:"), 1) + # Nothing changes before the owner agrees. + self.assertLess(out.index("Are you sure you want to do this? (y or n)"), out.index("Password for scott:")) + # The terminal shows what is typed, so the password goes only after + # kmutil's own Password: prompt has turned echo off. + log = Path(f"{calls}.kmutil-log").read_text() + self.assertIn("scott", log) + self.assertNotIn("secret", log) + self.assert_nothing_left_in_tmp() + + def test_a_password_with_spaces_reaches_every_tool_intact(self): + password = " two words " + result, kmutil_calls, calls = self.run_step2( + PROMPTING_KMUTIL, stdin=f"y\n{password}\n\n", password=password) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertEqual(Path(calls.parent, "piped").read_text(), f"y|scott|{password}") + + def test_the_owner_can_decline_before_anything_changes(self): + result, kmutil_calls, calls = self.run_step2("exit 0", stdin="n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("Nothing was changed", result.stdout) + self.assertNotIn("Password for", result.stdout) + self.assertFalse(Path(f"{calls}.bputil").exists()) + self.assertEqual(kmutil_calls, 0) + self.assert_nothing_left_in_tmp() + + def test_a_wrong_password_is_named_without_bputils_disclaimer(self): + result, kmutil_calls, _ = self.run_step2(PROMPTING_KMUTIL, stdin="y\nwrong\nsecret\n\n") + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertIn("That password didn't work for scott. Try again.", out) + self.assertNotIn("Use at your own risk", out) + # Each attempt says what it is doing while bputil works, and a + # rejected one replaces that line with the reason. + self.assertEqual(out.count("Updating Omarchy's security settings..."), 2) + # (Text-mode capture turns the carriage return into a newline.) + self.assertIn("\x1b[KThat password didn't work for scott.", out) + self.assertEqual(kmutil_calls, 1) + + def test_bputil_lets_macos_ask_after_three_failures(self): + result, _, calls = self.run_step2("exit 0", stdin="y\nw1\nw2\nw3\n\n", bputil_mode="fail") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("macOS asks itself now", result.stdout) + attempts = Path(f"{calls}.bputil").read_text().splitlines() + self.assertEqual(len(attempts), 4) + self.assertEqual(attempts[-1], "-nc -v 0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0") + + def test_a_kmutil_that_fails_is_handed_to_the_owner(self): + started = time.monotonic() + result, kmutil_calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\necho "Username: Password:"; exit 1') + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertEqual(kmutil_calls, 2) + self.assertIn("Type y, then your user name and password", out) + self.assertNotIn("Username:", out) + # A failure is handed over at once, not after the one-minute deadline. + self.assertLess(time.monotonic() - started, 8) + + def test_a_kmutil_that_stalls_is_stopped_before_the_owner_answers(self): + # It keeps its own command line, as kmutil does, so it can be recognized. + result, kmutil_calls, calls = self.run_step2( + '[ "$n" -gt 1 ] && exit 0\necho $$ >"$CALLS.stalled"\nwhile :; do sleep 1; done') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 2) + self.assertIn("Type y, then your user name and password", result.stdout) + # The stalled kmutil itself, not only script, is gone before the + # second one starts. + with self.assertRaises(ProcessLookupError): + os.kill(int(Path(f"{calls}.stalled").read_text()), 0) + + def test_ctrl_c_stops_everything_it_started(self): + import signal + script, env, calls = self.step2_with_fakes("exit 0", bputil_mode="hang") + process = subprocess.Popen([*self.shell, str(script)], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True) + process.stdin.write("y\nsecret\n") + process.stdin.flush() + hanging = Path(f"{calls}.bputil-pid") + deadline = time.monotonic() + 20 + while not hanging.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(hanging.exists(), "bputil never started") + # Ctrl-C reaches the terminal's whole foreground process group. + os.killpg(process.pid, signal.SIGINT) + out, _ = process.communicate(timeout=20) + self.assertEqual(process.returncode, 130) + self.assertIn("Stopped.", out) + time.sleep(0.5) + with self.assertRaises(ProcessLookupError): + os.killpg(process.pid, 0) + self.assert_nothing_left_in_tmp() + + def test_a_rejected_password_never_reaches_kmutil(self): + # After three rejected passwords bputil asks macOS itself; the third + # rejected one must not then be typed into kmutil. + started = time.monotonic() + result, kmutil_calls, calls = self.run_step2("exit 0", stdin="y\nw1\nw2\nw3\n\n", bputil_mode="fail") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertFalse(Path(f"{calls}.kmutil-log").exists(), "the hidden kmutil ran") + self.assertIn("Type y, then your user name and password", result.stdout) + self.assertLess(time.monotonic() - started, 8) + + def interrupt(self, kmutil_body, started_file, stdin, **options): + """Run step2.sh, wait for `started_file`, then press Ctrl-C.""" + import signal + script, env, calls = self.step2_with_fakes(kmutil_body, **options) + process = subprocess.Popen([*self.shell, str(script)], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True) + process.stdin.write(stdin) + process.stdin.flush() + started = Path(f"{calls}.{started_file}") + deadline = time.monotonic() + 20 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(started.exists(), f"{started_file} never appeared") + os.killpg(process.pid, signal.SIGINT) + out, _ = process.communicate(timeout=20) + self.assertEqual(process.returncode, 130) + self.assertIn("Stopped.", out) + return calls + + def test_ctrl_c_at_the_password_prompt_restores_echo(self): + # The fake stty's log appears when echo is turned off for the prompt. + calls = self.interrupt("exit 0", "stty", stdin="y\n") + self.assertEqual(Path(f"{calls}.stty").read_text().splitlines(), ["-echo", "echo"]) + self.assertFalse(Path(f"{calls}.bputil").exists()) + self.assert_nothing_left_in_tmp() + + def test_ctrl_c_during_the_hidden_kmutil_stops_it(self): + # A kmutil that ignores Ctrl-C, as one on its own hidden terminal would. + calls = self.interrupt( + "trap '' INT\necho $$ >\"$CALLS.kmutil-pid\"\nwhile :; do sleep 1; done", + "kmutil-pid", stdin="y\nsecret\n") + with self.assertRaises(ProcessLookupError): + os.kill(int(Path(f"{calls}.kmutil-pid").read_text()), 0) + self.assert_nothing_left_in_tmp() + + def test_a_volume_group_that_is_not_a_uuid_is_refused(self): + for vgid in ("", "VG-1", '0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0"; reboot; "'): + root = tempfile.TemporaryDirectory() + self.addCleanup(root.cleanup) + step2 = Path(root.name) / "step2.sh" + + class Stub: + def __init__(self, *args, vgid=vgid): + self.osi = SimpleNamespace(vgid=vgid, preboot_vgid="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809", + recovery=root.name) + self.step2_sh = str(step2) + + def install_files(self, cur_os): + pass + + with self.subTest(vgid=vgid), patch("omarchy_asahi.stub.StubInstaller", Stub), patch.dict( + os.environ, {"OMARCHY_MACHINE_OWNER": "scott", "OMARCHY_INSTALLER_NAME": self.title} + ), self.assertRaisesRegex(AsahiAdapterError, "volume group is invalid"): + stub_installer("sysinfo", "dutil", "osinfo").install_files("cur-os") + + def test_an_unpaired_recovery_blesses_with_the_known_owner(self): + result, kmutil_calls, calls = self.run_step2(PROMPTING_KMUTIL, stdin="secret\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertIn("Password for scott:", result.stdout) + root = calls.parent + self.assertEqual( + Path(f"{calls}.bless").read_text().splitlines(), + [f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass", "secret"], + ) + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + self.assertIn("choose Omarchy, and log in.", result.stdout) + self.assertEqual(kmutil_calls, 0) + + def test_an_unpaired_recovery_checks_the_startup_disk_not_bless(self): + # bless exits 0 after a wrong password, so only the startup disk tells. + result, _, calls = self.run_step2("exit 0", stdin="wrong\nsecret\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout.count("That password didn't work for scott. Try again."), 1) + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + + def test_an_unpaired_recovery_lets_bless_ask_after_three_failures(self): + result, _, calls = self.run_step2("exit 0", stdin="a\nb\nc\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertIn("macOS asks itself now", result.stdout) + self.assertEqual(Path(f"{calls}.bless").read_text().splitlines()[-1], + f"--setBoot --mount {calls.parent / 'Omarchy'}") + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + + def test_an_unpaired_recovery_already_starting_omarchy_asks_nothing(self): + result, _, calls = self.run_step2("exit 0", stdin="\n", paired=False, startup="omarchy") + self.assertEqual(result.returncode, 1) + self.assertIn("is already the", result.stdout) + self.assertNotIn("Password for", result.stdout) + self.assertFalse(Path(f"{calls}.bless").exists()) + + def test_every_line_fits_an_80_column_terminal(self): + text = self.make("scott").read_text() + for line in text.splitlines(): + match = re.match(r'\s*(?:echo|printf)\s+"(.*)"', line) + if not match: + continue + shown = re.sub(r"\$\{(BOLD|RST)\}", "", match.group(1)) + shown = shown.replace("$os_name", "Omarchy").replace("\\n", "") + self.assertLessEqual(len(shown), 76, shown) + + def test_an_unknown_owner_is_asked_for(self): + text = self.make("").read_text() + self.assertIn('OWNER=""', text) + self.assertIn('if [ -z "$OWNER" ]', text) + + def test_a_title_that_could_break_the_script_is_refused(self): + self.title = 'Probe"; reboot; "' + with self.assertRaises(AsahiAdapterError): + self.make("scott") + + def test_an_owner_that_could_break_the_script_is_refused(self): + with self.assertRaises(AsahiAdapterError): + self.make('scott"; rm -rf /; "') + + def test_the_template_has_only_its_four_placeholders(self): + import re as _re + self.assertEqual( + set(_re.findall(r"##[A-Z]+##", STEP2_SCRIPT)), + {"##TITLE##", "##VGID##", "##PREBOOT##", "##OWNER##"}, + ) + + + +@unittest.skipUnless(shutil.which("dash"), "dash is not installed") +class Step2ScriptDashTests(Step2ScriptTests): + """The same under dash: step2.sh is #!/bin/sh, so it must stay POSIX.""" + + shell = [shutil.which("dash") or "dash"] + # Linux's /bin/sh is often dash, so the fakes run under it too. + fake_shell = shutil.which("dash") or "dash" + if __name__ == "__main__": unittest.main() diff --git a/Engine/overlay/tests/test_omarchy_runtime.py b/Engine/overlay/tests/test_omarchy_runtime.py index 864df4d..afaf60d 100644 --- a/Engine/overlay/tests/test_omarchy_runtime.py +++ b/Engine/overlay/tests/test_omarchy_runtime.py @@ -70,6 +70,17 @@ def test_install_requires_the_complete_helper_environment(self): } ) + def test_a_released_app_without_the_installer_name_still_runs(self): + # Apps released before OMARCHY_INSTALLER_NAME must keep working with + # a newer engine; the Recovery setup then takes its title from DISTRO. + for mode in ("install", "retry-recovery-authorization"): + environment = self._install_environment() + environment["OMARCHY_ENGINE_MODE"] = mode + del environment["OMARCHY_INSTALLER_NAME"] + with self.subTest(mode=mode): + runtime = EngineRuntime.from_environment(environment) + self.assertEqual(runtime.mode, mode) + def test_install_requires_owner_and_recovery_branding(self): for key in ( "OMARCHY_MACHINE_OWNER", @@ -411,7 +422,8 @@ def _install_environment(self): "OMARCHY_ENGINE_BINDING_DIGEST": "sha256:" + "b" * 64, "OMARCHY_ENGINE_PLAN_DIGEST": self.plan.plan_digest, "OMARCHY_MACHINE_OWNER": "mina", - "DISTRO": "Omarchy MX Mac", + "DISTRO": "Omarchy", + "OMARCHY_INSTALLER_NAME": "Probe Installer", "DISTRO_DOCS": "https://omarchy.org/manual/", } diff --git a/Engine/rebuild-python-overlay.py b/Engine/rebuild-python-overlay.py index 9b84b4d..3046760 100644 --- a/Engine/rebuild-python-overlay.py +++ b/Engine/rebuild-python-overlay.py @@ -15,7 +15,7 @@ BASE_SHA256 = '9e9277384b6c9e8b269cc79b1b24df7bfcdcbb898a596a677b74d1d18050aebe' BASE_COMMIT = 'f0469cea0899f3efed8efead604174c7a53c4451' -VERSION = 'v0.9.2-omarchy.27' +VERSION = 'v0.9.2-omarchy.28' _SPEC = importlib.util.spec_from_file_location( 'verify_source_lock', Path(__file__).resolve().parent / 'verify-source-lock.py') diff --git a/Engine/source-lock.json b/Engine/source-lock.json index d527c55..a3b34a8 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -141,7 +141,7 @@ } }, "downstream_overlay": { - "version": "v0.9.2-omarchy.27", + "version": "v0.9.2-omarchy.28", "capability": "candidate_bound_full_os_stage_one_authenticated_recovery", "engine_modes": [ "inspect", @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "74bed0f998c906afc6fe72ef8c9f0d6fd6baac330e206e084d7ef2879840ab16" + "sha256": "a5d887b1d07a1977ff397980d247c9138a61cfffb675ce51aa18d07589030f0c" }, { "path": "overlay/src/omarchy_contract.py", @@ -196,7 +196,7 @@ { "path": "overlay/src/omarchy_runtime.py", "destination": "src/omarchy_runtime.py", - "sha256": "8d5296fe17fe1bac6340a8c2433d0968f9d7f32f262e8a927dd507ec1efb823d" + "sha256": "5e03ee9795d3651055dfd3ed24ae55658e949c85ffd029d99bc2713671965aae" }, { "path": "overlay/src/omarchy_stage1.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "b5c07f8a61273d25cfd1ef3fb76208241ebdb29ff2e2832ee16c2d63d01e9e00" + "sha256": "bf57c80bf26bbfc00a3cb24d1a5f8fc1196e8c155789fd4af97dddc0e4b7292a" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -236,7 +236,7 @@ { "path": "overlay/tests/test_omarchy_runtime.py", "destination": "tests/test_omarchy_runtime.py", - "sha256": "02f4a46d6159f7e189b6e2349dfe43184a6e99256a51a066dd95d02f0500ee57" + "sha256": "94302801fed80159a9369ec7963ee073d2181d843a70642f489c6d48018ea6b0" }, { "path": "overlay/tests/test_omarchy_stage1.py", @@ -270,13 +270,13 @@ }, { "path": "rebuild-python-overlay.py", - "sha256": "e4a6c4f9a3b5e9410d2511c16bf85899e9630713791acf8c59c669bce15e02d0" + "sha256": "3cc29ff5c7bd834b40a50ef2271417efcd33bc7bc747b11c743d5ba2042017c2" } ], "validation_artifact": { - "filename": "installer-v0.9.2-omarchy.27.tar.gz", - "size_bytes": 17839422, - "sha256": "4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a", + "filename": "installer-v0.9.2-omarchy.28.tar.gz", + "size_bytes": 17843348, + "sha256": "0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146", "reproducibility_scope": "two-identical-python-overlay-repacks-authenticated-base", "signature": "absent", "metadata_sha256": "2e6181ce6b6e17c11039e04bfadade8d10ae0e11889885ad8c9960b68f179a5d" diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index 4cb7f38..5ef6bd3 100755 --- a/Packaging/build-app.sh +++ b/Packaging/build-app.sh @@ -46,8 +46,8 @@ helper_identifier="$INSTALLER_HELPER_IDENTIFIER" app_name="$INSTALLER_APP_NAME.app" app_executable_name="OmarchyAppleInstallerApp" daemon_plist_name="$helper_identifier.plist" -engine_file_name="installer-v0.9.2-omarchy.27.tar.gz" -engine_digest="4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" +engine_file_name="installer-v0.9.2-omarchy.28.tar.gz" +engine_digest="0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" if [[ $signing_identity == "-" ]]; then client_requirement="identifier \"$app_identifier\"" diff --git a/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift b/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift index 4804c8d..323b0c8 100644 --- a/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift +++ b/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift @@ -423,7 +423,8 @@ "OMARCHY_ENGINE_BINDING_DIGEST": package.bindingDigest, "OMARCHY_ENGINE_PLAN_DIGEST": package.planDigest, "OMARCHY_MACHINE_OWNER": authorization.username, - "DISTRO": "Omarchy MX Mac", + "DISTRO": "Omarchy", + "OMARCHY_INSTALLER_NAME": InstallerProductIdentity.appName, "DISTRO_DOCS": "https://omarchy.org/manual/", ] if let repairManifestURL = package.repairManifestURL { diff --git a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift index 9807888..0396ad7 100644 --- a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift +++ b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift @@ -18,11 +18,11 @@ /// An installation engine fix ships in a catalog without rebuilding /// and re-notarizing the app. Nothing may require them to be equal. public struct ValidationEngineArtifactLocator: Sendable { - public static let version = "v0.9.2-omarchy.27" - public static let fileName = "installer-v0.9.2-omarchy.27.tar.gz" + public static let version = "v0.9.2-omarchy.28" + public static let fileName = "installer-v0.9.2-omarchy.28.tar.gz" public static let expectedDigest = - "sha256:4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" - public static let expectedSizeBytes: UInt64 = 17_839_422 + "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" + public static let expectedSizeBytes: UInt64 = 17_843_348 public init() {} diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift index a7a8ddc..362fc8d 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift @@ -565,7 +565,8 @@ ;; install|retry-recovery-authorization) [ "$OMARCHY_ENGINE_MODE" = "\(expectedInstallMode)" ] || exit 64 - [ "$DISTRO" = "Omarchy MX Mac" ] || exit 68 + [ "$DISTRO" = "Omarchy" ] || exit 68 + [ "$OMARCHY_INSTALLER_NAME" = "\(InstallerProductIdentity.appName)" ] || exit 83 [ "$DISTRO_DOCS" = "https://omarchy.org/manual/" ] || exit 69 [ "$OMARCHY_MACHINE_OWNER" = "mina" ] || exit 67 IFS= read -r owner_password || exit 66 diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift index c7bb3d8..fafa9b5 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift @@ -8,19 +8,19 @@ func testM3CapableInspectionIdentityIsPinnedExactly() { XCTAssertEqual( ValidationEngineArtifactLocator.version, - "v0.9.2-omarchy.27" + "v0.9.2-omarchy.28" ) XCTAssertEqual( ValidationEngineArtifactLocator.fileName, - "installer-v0.9.2-omarchy.27.tar.gz" + "installer-v0.9.2-omarchy.28.tar.gz" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedDigest, - "sha256:4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" + "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedSizeBytes, - 17_839_422 + 17_843_348 ) } diff --git a/docs/extraction.md b/docs/extraction.md index 029cec0..3f638f4 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -75,8 +75,10 @@ After merging current `main` on 2026-10-03, two repacks with `/usr/bin/python3` ## Stub probe and divider margin fixes -Engine `.27` stops probing stub containers. A stub carries the version of the macOS it was made from, so a versioned OS alone admitted it to the limits probes, and one failed probe of a stub on a Mac with an existing Asahi or Omarchy install failed the whole inventory. The filter now admits only versioned OSes that are not stubs. The pinned archive is `installer-v0.9.2-omarchy.27.tar.gz`, 17,839,422 bytes, SHA-256 `4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes from the authenticated `.14` base and locked v0.9.2 checkout; compared with `.20`, only `omarchy_runtime.py` and `version.tag` changed. The bundled inspection pins and both release-input templates select `.27`. +Engine `.27` stops probing stub containers. A stub carries the version of the macOS it was made from, so a versioned OS alone admitted it to the limits probes, and one failed probe of a stub on a Mac with an existing Asahi or Omarchy install failed the whole inventory. The filter now admits only versioned OSes that are not stubs. The pinned archive is `installer-v0.9.2-omarchy.27.tar.gz`, 17,839,422 bytes, SHA-256 `4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes from the authenticated `.14` base and locked v0.9.2 checkout; compared with `.20`, only `omarchy_runtime.py` and `version.tag` changed. At #40, the bundled inspection pins and both release-input templates selected `.27`. Swift now withholds the resize drift margin before adopting the recommended (doubled) Omarchy size as the minimum. Adopting it first left a band of free-space states where the minimum equalled the maximum: the divider could not move and no margin remained for the engine's admission check. -Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.27`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. +The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,843,348 bytes, SHA-256 `0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146`: the `.27` overlay with the Recovery step from #39 added. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.27`, only `omarchy_asahi.py`, `omarchy_runtime.py` and `version.tag` changed. The bundled inspection pins and packager select `.28` too, so the release scripts, which take the catalog engine from `Packaging/build-app.sh`, publish the engine the templates name. + +Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.28`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. diff --git a/scripts/release-inputs-aurora.template.json b/scripts/release-inputs-aurora.template.json index 2982bec..12896f5 100644 --- a/scripts/release-inputs-aurora.template.json +++ b/scripts/release-inputs-aurora.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-aurora-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.27.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.27", + "engine_version": "v0.9.2-omarchy.28", "evidence_revision": "4.0.3-mac.2.20260913-aurora", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", diff --git a/scripts/release-inputs.template.json b/scripts/release-inputs.template.json index afe8cd5..a72dd84 100644 --- a/scripts/release-inputs.template.json +++ b/scripts/release-inputs.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-asahi-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.27.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.27", + "engine_version": "v0.9.2-omarchy.28", "evidence_revision": "4.0.3-mac.1.20260913", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c",