|
| 1 | +"""Asahi Linux boot chain model for sourceos-boot. |
| 2 | +
|
| 3 | +Models the m1n1 → U-Boot → systemd-boot chain specific to Apple Silicon |
| 4 | +devices running Asahi Linux. Provides a non-mutating rollback plan that |
| 5 | +describes what a future executor would need to do to return to the previous |
| 6 | +NixOS generation. |
| 7 | +
|
| 8 | +Boundary invariant: no disk writes, no EFI var mutations, no kexec, no |
| 9 | +subprocess calls that modify state. plan_rollback() is pure. |
| 10 | +
|
| 11 | +Key Asahi constraint: efiVarsMutable MUST be false. NixOS systemd-boot |
| 12 | +integration must be configured with canTouchEfiVariables = false or boot |
| 13 | +entries may conflict with macOS's EFI namespace. |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import os |
| 19 | +from dataclasses import dataclass |
| 20 | +from typing import Any |
| 21 | + |
| 22 | +BOOT_CHAIN_TYPE = "asahi-m1n1-uboot-systemd-boot" |
| 23 | +ASAHI_BOOT_SCHEMA = "sourceos.asahi-boot-chain/v0.1" |
| 24 | + |
| 25 | +# The NixOS generations directory on a standard NixOS install |
| 26 | +NIX_PROFILES_SYSTEM = "/nix/var/nix/profiles" |
| 27 | +CURRENT_SYSTEM_LINK = "/run/current-system" |
| 28 | +SYSTEM_PROFILE = "system" |
| 29 | + |
| 30 | + |
| 31 | +@dataclass(frozen=True) |
| 32 | +class AsahiBootChainInfo: |
| 33 | + """Static description of the Asahi boot chain for provenance records.""" |
| 34 | + |
| 35 | + chain_type: str |
| 36 | + m1n1_version: str | None |
| 37 | + uboot_version: str | None |
| 38 | + efi_vars_mutable: bool = False |
| 39 | + |
| 40 | + def to_dict(self) -> dict[str, Any]: |
| 41 | + return { |
| 42 | + "type": self.chain_type, |
| 43 | + "m1n1Version": self.m1n1_version, |
| 44 | + "ubootVersion": self.uboot_version, |
| 45 | + "efiVarsMutable": self.efi_vars_mutable, |
| 46 | + } |
| 47 | + |
| 48 | + def validate(self) -> list[str]: |
| 49 | + """Return a list of invariant violations. Empty list = valid.""" |
| 50 | + issues = [] |
| 51 | + if self.efi_vars_mutable: |
| 52 | + issues.append( |
| 53 | + "efiVarsMutable must be false on Apple Silicon — " |
| 54 | + "set boot.loader.efi.canTouchEfiVariables = false in NixOS config" |
| 55 | + ) |
| 56 | + if self.chain_type != BOOT_CHAIN_TYPE: |
| 57 | + issues.append(f"unexpected chain_type {self.chain_type!r}; expected {BOOT_CHAIN_TYPE!r}") |
| 58 | + return issues |
| 59 | + |
| 60 | + |
| 61 | +@dataclass(frozen=True) |
| 62 | +class NixOSGeneration: |
| 63 | + number: int |
| 64 | + store_path: str |
| 65 | + is_current: bool = False |
| 66 | + |
| 67 | + def to_dict(self) -> dict[str, Any]: |
| 68 | + return { |
| 69 | + "number": self.number, |
| 70 | + "store_path": self.store_path, |
| 71 | + "is_current": self.is_current, |
| 72 | + } |
| 73 | + |
| 74 | + |
| 75 | +@dataclass(frozen=True) |
| 76 | +class AsahiRollbackPlan: |
| 77 | + """Non-mutating rollback plan for an Asahi-booted NixOS device.""" |
| 78 | + |
| 79 | + schema: str |
| 80 | + chain: AsahiBootChainInfo |
| 81 | + current_generation: NixOSGeneration | None |
| 82 | + rollback_target: NixOSGeneration | None |
| 83 | + policy_gate: str |
| 84 | + policy_reason: str |
| 85 | + steps: list[str] |
| 86 | + |
| 87 | + def to_dict(self) -> dict[str, Any]: |
| 88 | + return { |
| 89 | + "schema": self.schema, |
| 90 | + "chain": self.chain.to_dict(), |
| 91 | + "current_generation": self.current_generation.to_dict() if self.current_generation else None, |
| 92 | + "rollback_target": self.rollback_target.to_dict() if self.rollback_target else None, |
| 93 | + "policy_gate": self.policy_gate, |
| 94 | + "policy_reason": self.policy_reason, |
| 95 | + "steps": self.steps, |
| 96 | + } |
| 97 | + |
| 98 | + @property |
| 99 | + def allowed(self) -> bool: |
| 100 | + return self.policy_gate == "allowed" |
| 101 | + |
| 102 | + |
| 103 | +class AsahiBootChain: |
| 104 | + """Models the Asahi Linux boot chain and provides rollback planning. |
| 105 | +
|
| 106 | + Reads the NixOS profile symlink tree to detect current and previous |
| 107 | + generations. All reads are from /nix/var/nix/profiles and /run — |
| 108 | + no writes, no subprocess calls. |
| 109 | + """ |
| 110 | + |
| 111 | + def __init__( |
| 112 | + self, |
| 113 | + chain_info: AsahiBootChainInfo | None = None, |
| 114 | + profiles_root: str = NIX_PROFILES_SYSTEM, |
| 115 | + current_link: str = CURRENT_SYSTEM_LINK, |
| 116 | + ) -> None: |
| 117 | + self._chain = chain_info or AsahiBootChainInfo( |
| 118 | + chain_type=BOOT_CHAIN_TYPE, |
| 119 | + m1n1_version=None, |
| 120 | + uboot_version=None, |
| 121 | + efi_vars_mutable=False, |
| 122 | + ) |
| 123 | + self._profiles_root = profiles_root |
| 124 | + self._current_link = current_link |
| 125 | + |
| 126 | + def detect_generations(self) -> list[NixOSGeneration]: |
| 127 | + """Read NixOS system profile symlinks and return all known generations. |
| 128 | +
|
| 129 | + Returns an empty list if the profiles directory doesn't exist (e.g. |
| 130 | + running on macOS for testing). |
| 131 | + """ |
| 132 | + generations = [] |
| 133 | + system_profile_dir = os.path.join(self._profiles_root, SYSTEM_PROFILE) |
| 134 | + |
| 135 | + if not os.path.isdir(system_profile_dir): |
| 136 | + return generations |
| 137 | + |
| 138 | + current_path = None |
| 139 | + if os.path.islink(self._current_link): |
| 140 | + try: |
| 141 | + current_path = os.path.realpath(self._current_link) |
| 142 | + except OSError: |
| 143 | + pass |
| 144 | + |
| 145 | + for entry in sorted(os.listdir(system_profile_dir)): |
| 146 | + # NixOS generation symlinks are named system-<N>-link |
| 147 | + if not entry.startswith(SYSTEM_PROFILE + "-") or not entry.endswith("-link"): |
| 148 | + continue |
| 149 | + try: |
| 150 | + num_str = entry[len(SYSTEM_PROFILE) + 1 : -len("-link")] |
| 151 | + gen_num = int(num_str) |
| 152 | + except ValueError: |
| 153 | + continue |
| 154 | + link_path = os.path.join(system_profile_dir, entry) |
| 155 | + try: |
| 156 | + store_path = os.path.realpath(link_path) |
| 157 | + except OSError: |
| 158 | + continue |
| 159 | + is_current = current_path is not None and store_path == current_path |
| 160 | + generations.append(NixOSGeneration( |
| 161 | + number=gen_num, |
| 162 | + store_path=store_path, |
| 163 | + is_current=is_current, |
| 164 | + )) |
| 165 | + |
| 166 | + return sorted(generations, key=lambda g: g.number) |
| 167 | + |
| 168 | + def plan_rollback(self) -> AsahiRollbackPlan: |
| 169 | + """Return a non-mutating rollback plan. No writes performed.""" |
| 170 | + |
| 171 | + violations = self._chain.validate() |
| 172 | + if violations: |
| 173 | + return AsahiRollbackPlan( |
| 174 | + schema=ASAHI_BOOT_SCHEMA, |
| 175 | + chain=self._chain, |
| 176 | + current_generation=None, |
| 177 | + rollback_target=None, |
| 178 | + policy_gate="denied", |
| 179 | + policy_reason="; ".join(violations), |
| 180 | + steps=[], |
| 181 | + ) |
| 182 | + |
| 183 | + generations = self.detect_generations() |
| 184 | + current = next((g for g in generations if g.is_current), None) |
| 185 | + |
| 186 | + if not generations: |
| 187 | + # Running outside of a NixOS device (e.g. CI on macOS) — |
| 188 | + # emit a plan that describes the intent without real paths |
| 189 | + return AsahiRollbackPlan( |
| 190 | + schema=ASAHI_BOOT_SCHEMA, |
| 191 | + chain=self._chain, |
| 192 | + current_generation=None, |
| 193 | + rollback_target=None, |
| 194 | + policy_gate="allowed", |
| 195 | + policy_reason="no NixOS generations detected — rollback command shown for reference", |
| 196 | + steps=["nixos-rebuild switch --rollback"], |
| 197 | + ) |
| 198 | + |
| 199 | + prev_gens = [g for g in generations if not g.is_current] |
| 200 | + rollback_target = prev_gens[-1] if prev_gens else None |
| 201 | + |
| 202 | + if rollback_target is None: |
| 203 | + return AsahiRollbackPlan( |
| 204 | + schema=ASAHI_BOOT_SCHEMA, |
| 205 | + chain=self._chain, |
| 206 | + current_generation=current, |
| 207 | + rollback_target=None, |
| 208 | + policy_gate="denied", |
| 209 | + policy_reason="no previous generation available to roll back to", |
| 210 | + steps=[], |
| 211 | + ) |
| 212 | + |
| 213 | + steps = [ |
| 214 | + f"nixos-rebuild switch --rollback", |
| 215 | + f"# rolls back to generation {rollback_target.number}: {rollback_target.store_path}", |
| 216 | + f"# efiVarsMutable=false enforced — boot entry managed by systemd-boot, not EFI vars", |
| 217 | + ] |
| 218 | + |
| 219 | + return AsahiRollbackPlan( |
| 220 | + schema=ASAHI_BOOT_SCHEMA, |
| 221 | + chain=self._chain, |
| 222 | + current_generation=current, |
| 223 | + rollback_target=rollback_target, |
| 224 | + policy_gate="allowed", |
| 225 | + policy_reason=f"rollback from generation {current.number if current else '?'} " |
| 226 | + f"to generation {rollback_target.number} via nixos-rebuild --rollback", |
| 227 | + steps=steps, |
| 228 | + ) |
0 commit comments