diff --git a/examples/builder-aarch64-dev-release.example.json b/examples/builder-aarch64-dev-release.example.json new file mode 100644 index 0000000..0bb5d61 --- /dev/null +++ b/examples/builder-aarch64-dev-release.example.json @@ -0,0 +1,85 @@ +{ + "apiVersion": "sourceos.dev/v1", + "kind": "BootReleaseSet", + "metadata": { + "name": "sourceos-builder-aarch64-dev", + "version": "0.1.0", + "createdAt": "2026-06-16T00:00:00Z", + "labels": { + "surface": "local-katello", + "platform": "apple-silicon", + "stage": "dev", + "entry-type": "live", + "katello-content-view": "sourceos-builder-aarch64", + "lifecycle-env": "dev" + } + }, + "spec": { + "releaseSetRef": "katello:SocioProphet/sourceos-builder-aarch64/dev", + "bootChain": { + "type": "asahi-m1n1-uboot-systemd-boot", + "asahi": { + "m1n1Version": "1.4.x", + "ubootVersion": "2024.01", + "efiVarsMutable": false, + "rollbackTarget": null + } + }, + "platforms": ["apple-silicon"], + "channels": ["live"], + "artifacts": [ + { + "name": "sourceos-builder-aarch64-closure", + "role": "rootfs", + "uri": "http://127.0.0.1:8101/socioprophet/content/sourceos/sourceos-closures-aarch64/builder-aarch64-0.1.0.nar.zst", + "sha256": "0000000000000000000000000000000000000000000000000000000000000001", + "sizeBytes": 0, + "contentType": "application/x-nix-nar" + }, + { + "name": "sourceos-nix-cache-index", + "role": "manifest", + "uri": "http://127.0.0.1:8101/socioprophet/content/sourceos/nix-cache-aarch64-linux/nix-cache-info", + "sha256": "0000000000000000000000000000000000000000000000000000000000000002", + "sizeBytes": 0, + "contentType": "text/plain" + } + ], + "policy": { + "network": "restricted", + "diskWrite": "allowed", + "tokenRequired": false, + "allowedActions": ["fetch", "verify", "install", "rollback"] + }, + "evidence": { + "correlationId": "builder-aarch64-dev-phase0", + "requiredReports": ["manifest-hash", "verification-result", "selected-channel", "install-result"] + }, + "provenance": { + "builderId": "local-katello-sourceos-builder-aarch64", + "sourceRefs": [ + "git:SociOS-Linux/source-os#main", + "katello:SocioProphet/sourceos-builder-aarch64/dev" + ], + "attestations": ["slsa"] + }, + "trust": { + "model": "static-root", + "rootRef": "local-dev-trust-root", + "metadataRef": "local-dev-metadata" + }, + "signature": { + "type": "minisign", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000010" + }, + "antiRollback": { + "minimumVersion": "0.1.0", + "allowOfflineFallback": true, + "allowedRollbackRefs": [] + }, + "telemetry": { + "traceRequired": false, + "metricSet": ["boot-duration", "verify-duration", "download-bytes", "action-result"] + } + } +} diff --git a/schemas/boot-release-set.schema.json b/schemas/boot-release-set.schema.json index b78a685..1d69e87 100644 --- a/schemas/boot-release-set.schema.json +++ b/schemas/boot-release-set.schema.json @@ -25,6 +25,32 @@ "required": ["platforms", "channels", "artifacts", "policy", "evidence", "provenance", "trust", "signature", "antiRollback", "telemetry"], "properties": { "releaseSetRef": {"type": "string"}, + "bootChain": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "enum": [ + "asahi-m1n1-uboot-systemd-boot", + "uefi-systemd-boot", + "uefi-grub", + "coreboot", + "uboot-generic" + ] + }, + "asahi": { + "type": "object", + "additionalProperties": false, + "properties": { + "m1n1Version": {"type": "string"}, + "ubootVersion": {"type": "string"}, + "efiVarsMutable": {"type": "boolean"}, + "rollbackTarget": {"type": ["string", "null"], "description": "NixOS store path of rollback generation; null when no prior generation exists"} + } + } + } + }, "platforms": {"type": "array", "items": {"enum": ["apple-silicon", "uefi-x86_64", "uefi-aarch64", "generic-arm64"]}, "minItems": 1, "uniqueItems": true}, "channels": {"type": "array", "items": {"enum": ["live", "installer", "recovery", "rollback", "rescue"]}, "minItems": 1, "uniqueItems": true}, "artifacts": {"type": "array", "minItems": 1, "items": {"type": "object", "additionalProperties": false, "required": ["name", "role", "uri", "sha256"], "properties": {"name": {"type": "string"}, "role": {"enum": ["kernel", "initrd", "rootfs", "manifest", "bootloader", "recovery-image", "installer-data", "signature", "attestation", "tuf-metadata", "other"]}, "uri": {"type": "string", "format": "uri"}, "sha256": {"type": "string", "pattern": "^[a-fA-F0-9]{64}$"}, "sizeBytes": {"type": "integer", "minimum": 0}, "contentType": {"type": "string"}}}}, diff --git a/src/sourceos_boot/asahi_boot_chain.py b/src/sourceos_boot/asahi_boot_chain.py new file mode 100644 index 0000000..60a9579 --- /dev/null +++ b/src/sourceos_boot/asahi_boot_chain.py @@ -0,0 +1,228 @@ +"""Asahi Linux boot chain model for sourceos-boot. + +Models the m1n1 → U-Boot → systemd-boot chain specific to Apple Silicon +devices running Asahi Linux. Provides a non-mutating rollback plan that +describes what a future executor would need to do to return to the previous +NixOS generation. + +Boundary invariant: no disk writes, no EFI var mutations, no kexec, no +subprocess calls that modify state. plan_rollback() is pure. + +Key Asahi constraint: efiVarsMutable MUST be false. NixOS systemd-boot +integration must be configured with canTouchEfiVariables = false or boot +entries may conflict with macOS's EFI namespace. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +BOOT_CHAIN_TYPE = "asahi-m1n1-uboot-systemd-boot" +ASAHI_BOOT_SCHEMA = "sourceos.asahi-boot-chain/v0.1" + +# The NixOS generations directory on a standard NixOS install +NIX_PROFILES_SYSTEM = "/nix/var/nix/profiles" +CURRENT_SYSTEM_LINK = "/run/current-system" +SYSTEM_PROFILE = "system" + + +@dataclass(frozen=True) +class AsahiBootChainInfo: + """Static description of the Asahi boot chain for provenance records.""" + + chain_type: str + m1n1_version: str | None + uboot_version: str | None + efi_vars_mutable: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.chain_type, + "m1n1Version": self.m1n1_version, + "ubootVersion": self.uboot_version, + "efiVarsMutable": self.efi_vars_mutable, + } + + def validate(self) -> list[str]: + """Return a list of invariant violations. Empty list = valid.""" + issues = [] + if self.efi_vars_mutable: + issues.append( + "efiVarsMutable must be false on Apple Silicon — " + "set boot.loader.efi.canTouchEfiVariables = false in NixOS config" + ) + if self.chain_type != BOOT_CHAIN_TYPE: + issues.append(f"unexpected chain_type {self.chain_type!r}; expected {BOOT_CHAIN_TYPE!r}") + return issues + + +@dataclass(frozen=True) +class NixOSGeneration: + number: int + store_path: str + is_current: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "number": self.number, + "store_path": self.store_path, + "is_current": self.is_current, + } + + +@dataclass(frozen=True) +class AsahiRollbackPlan: + """Non-mutating rollback plan for an Asahi-booted NixOS device.""" + + schema: str + chain: AsahiBootChainInfo + current_generation: NixOSGeneration | None + rollback_target: NixOSGeneration | None + policy_gate: str + policy_reason: str + steps: list[str] + + def to_dict(self) -> dict[str, Any]: + return { + "schema": self.schema, + "chain": self.chain.to_dict(), + "current_generation": self.current_generation.to_dict() if self.current_generation else None, + "rollback_target": self.rollback_target.to_dict() if self.rollback_target else None, + "policy_gate": self.policy_gate, + "policy_reason": self.policy_reason, + "steps": self.steps, + } + + @property + def allowed(self) -> bool: + return self.policy_gate == "allowed" + + +class AsahiBootChain: + """Models the Asahi Linux boot chain and provides rollback planning. + + Reads the NixOS profile symlink tree to detect current and previous + generations. All reads are from /nix/var/nix/profiles and /run — + no writes, no subprocess calls. + """ + + def __init__( + self, + chain_info: AsahiBootChainInfo | None = None, + profiles_root: str = NIX_PROFILES_SYSTEM, + current_link: str = CURRENT_SYSTEM_LINK, + ) -> None: + self._chain = chain_info or AsahiBootChainInfo( + chain_type=BOOT_CHAIN_TYPE, + m1n1_version=None, + uboot_version=None, + efi_vars_mutable=False, + ) + self._profiles_root = profiles_root + self._current_link = current_link + + def detect_generations(self) -> list[NixOSGeneration]: + """Read NixOS system profile symlinks and return all known generations. + + Returns an empty list if the profiles directory doesn't exist (e.g. + running on macOS for testing). + """ + generations = [] + system_profile_dir = os.path.join(self._profiles_root, SYSTEM_PROFILE) + + if not os.path.isdir(system_profile_dir): + return generations + + current_path = None + if os.path.islink(self._current_link): + try: + current_path = os.path.realpath(self._current_link) + except OSError: + pass + + for entry in sorted(os.listdir(system_profile_dir)): + # NixOS generation symlinks are named system--link + if not entry.startswith(SYSTEM_PROFILE + "-") or not entry.endswith("-link"): + continue + try: + num_str = entry[len(SYSTEM_PROFILE) + 1 : -len("-link")] + gen_num = int(num_str) + except ValueError: + continue + link_path = os.path.join(system_profile_dir, entry) + try: + store_path = os.path.realpath(link_path) + except OSError: + continue + is_current = current_path is not None and store_path == current_path + generations.append(NixOSGeneration( + number=gen_num, + store_path=store_path, + is_current=is_current, + )) + + return sorted(generations, key=lambda g: g.number) + + def plan_rollback(self) -> AsahiRollbackPlan: + """Return a non-mutating rollback plan. No writes performed.""" + + violations = self._chain.validate() + if violations: + return AsahiRollbackPlan( + schema=ASAHI_BOOT_SCHEMA, + chain=self._chain, + current_generation=None, + rollback_target=None, + policy_gate="denied", + policy_reason="; ".join(violations), + steps=[], + ) + + generations = self.detect_generations() + current = next((g for g in generations if g.is_current), None) + + if not generations: + # Running outside of a NixOS device (e.g. CI on macOS) — + # emit a plan that describes the intent without real paths + return AsahiRollbackPlan( + schema=ASAHI_BOOT_SCHEMA, + chain=self._chain, + current_generation=None, + rollback_target=None, + policy_gate="allowed", + policy_reason="no NixOS generations detected — rollback command shown for reference", + steps=["nixos-rebuild switch --rollback"], + ) + + prev_gens = [g for g in generations if not g.is_current] + rollback_target = prev_gens[-1] if prev_gens else None + + if rollback_target is None: + return AsahiRollbackPlan( + schema=ASAHI_BOOT_SCHEMA, + chain=self._chain, + current_generation=current, + rollback_target=None, + policy_gate="denied", + policy_reason="no previous generation available to roll back to", + steps=[], + ) + + steps = [ + f"nixos-rebuild switch --rollback", + f"# rolls back to generation {rollback_target.number}: {rollback_target.store_path}", + f"# efiVarsMutable=false enforced — boot entry managed by systemd-boot, not EFI vars", + ] + + return AsahiRollbackPlan( + schema=ASAHI_BOOT_SCHEMA, + chain=self._chain, + current_generation=current, + rollback_target=rollback_target, + policy_gate="allowed", + policy_reason=f"rollback from generation {current.number if current else '?'} " + f"to generation {rollback_target.number} via nixos-rebuild --rollback", + steps=steps, + ) diff --git a/tests/test_asahi_boot_chain.py b/tests/test_asahi_boot_chain.py new file mode 100644 index 0000000..69c86cc --- /dev/null +++ b/tests/test_asahi_boot_chain.py @@ -0,0 +1,195 @@ +"""Tests for AsahiBootChain rollback planning.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +from sourceos_boot.asahi_boot_chain import ( + ASAHI_BOOT_SCHEMA, + BOOT_CHAIN_TYPE, + AsahiBootChain, + AsahiBootChainInfo, + NixOSGeneration, +) + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_FILE = ROOT / "schemas" / "boot-release-set.schema.json" + + +# ── AsahiBootChainInfo.validate ──────────────────────────────────────────── + +def test_valid_chain_no_violations(): + chain = AsahiBootChainInfo( + chain_type=BOOT_CHAIN_TYPE, + m1n1_version="1.4.x", + uboot_version="2024.01", + efi_vars_mutable=False, + ) + assert chain.validate() == [] + + +def test_efi_vars_mutable_is_violation(): + chain = AsahiBootChainInfo( + chain_type=BOOT_CHAIN_TYPE, + m1n1_version=None, + uboot_version=None, + efi_vars_mutable=True, + ) + violations = chain.validate() + assert len(violations) == 1 + assert "efiVarsMutable" in violations[0] + + +def test_wrong_chain_type_is_violation(): + chain = AsahiBootChainInfo( + chain_type="uefi-grub", + m1n1_version=None, + uboot_version=None, + efi_vars_mutable=False, + ) + violations = chain.validate() + assert any("chain_type" in v for v in violations) + + +def test_to_dict_contains_required_keys(): + chain = AsahiBootChainInfo( + chain_type=BOOT_CHAIN_TYPE, + m1n1_version="1.4.x", + uboot_version="2024.01", + efi_vars_mutable=False, + ) + d = chain.to_dict() + assert d["type"] == BOOT_CHAIN_TYPE + assert d["efiVarsMutable"] is False + assert d["m1n1Version"] == "1.4.x" + + +# ── AsahiBootChain.plan_rollback — no device ────────────────────────────── + +def test_plan_rollback_no_profiles_dir(): + """Outside a NixOS device: returns allowed plan with reference command.""" + chain = AsahiBootChain(profiles_root="/nonexistent/profiles") + plan = chain.plan_rollback() + assert plan.allowed + assert plan.policy_gate == "allowed" + assert any("nixos-rebuild" in s for s in plan.steps) + + +def test_plan_rollback_denied_when_efi_vars_mutable(): + info = AsahiBootChainInfo( + chain_type=BOOT_CHAIN_TYPE, + m1n1_version=None, + uboot_version=None, + efi_vars_mutable=True, + ) + chain = AsahiBootChain(chain_info=info, profiles_root="/nonexistent") + plan = chain.plan_rollback() + assert plan.policy_gate == "denied" + assert not plan.allowed + + +# ── AsahiBootChain.plan_rollback — simulated NixOS profiles ─────────────── + +def _make_profiles(tmpdir: str, num_generations: int, current_gen: int) -> tuple[str, str]: + """Create a fake NixOS profiles dir with symlinks.""" + profiles = os.path.join(tmpdir, "nix", "var", "nix", "profiles", "system") + os.makedirs(profiles) + current_link = os.path.join(tmpdir, "current-system") + + for i in range(1, num_generations + 1): + store_path = os.path.join(tmpdir, f"nix", "store", f"gen-{i}-drv") + os.makedirs(store_path, exist_ok=True) + link = os.path.join(profiles, f"system-{i}-link") + os.symlink(store_path, link) + if i == current_gen: + os.symlink(store_path, current_link) + + return profiles, current_link + + +def test_plan_rollback_single_generation_denied(): + with tempfile.TemporaryDirectory() as tmpdir: + profiles_root = os.path.join(tmpdir, "nix", "var", "nix", "profiles") + profiles, current_link = _make_profiles(tmpdir, num_generations=1, current_gen=1) + chain = AsahiBootChain( + profiles_root=profiles_root, + current_link=current_link, + ) + plan = chain.plan_rollback() + assert plan.policy_gate == "denied" + assert "no previous generation" in plan.policy_reason + + +def test_plan_rollback_two_generations_allowed(): + with tempfile.TemporaryDirectory() as tmpdir: + profiles_root = os.path.join(tmpdir, "nix", "var", "nix", "profiles") + profiles, current_link = _make_profiles(tmpdir, num_generations=2, current_gen=2) + chain = AsahiBootChain( + profiles_root=profiles_root, + current_link=current_link, + ) + plan = chain.plan_rollback() + assert plan.allowed + assert plan.rollback_target is not None + assert plan.rollback_target.number == 1 + assert plan.current_generation is not None + assert plan.current_generation.number == 2 + + +def test_plan_rollback_targets_most_recent_previous(): + """With three generations, rolls back to gen 2, not gen 1.""" + with tempfile.TemporaryDirectory() as tmpdir: + profiles_root = os.path.join(tmpdir, "nix", "var", "nix", "profiles") + profiles, current_link = _make_profiles(tmpdir, num_generations=3, current_gen=3) + chain = AsahiBootChain( + profiles_root=profiles_root, + current_link=current_link, + ) + plan = chain.plan_rollback() + assert plan.allowed + assert plan.rollback_target.number == 2 + + +def test_plan_rollback_to_dict_schema(): + chain = AsahiBootChain(profiles_root="/nonexistent") + plan = chain.plan_rollback() + d = plan.to_dict() + assert d["schema"] == ASAHI_BOOT_SCHEMA + assert "chain" in d + assert "policy_gate" in d + assert "steps" in d + + +# ── BootReleaseSet schema: bootChain field ──────────────────────────────── + +@pytest.fixture(scope="module") +def brs_schema() -> dict: + data = json.loads(SCHEMA_FILE.read_text()) + Draft202012Validator.check_schema(data) + return data + + +def test_boot_chain_field_in_schema(brs_schema): + spec_props = brs_schema["properties"]["spec"]["properties"] + assert "bootChain" in spec_props + + +def test_boot_chain_type_enum(brs_schema): + boot_chain = brs_schema["properties"]["spec"]["properties"]["bootChain"] + chain_types = boot_chain["properties"]["type"]["enum"] + assert "asahi-m1n1-uboot-systemd-boot" in chain_types + assert "uefi-systemd-boot" in chain_types + + +def test_example_fixture_validates(brs_schema): + example = json.loads( + (ROOT / "examples" / "builder-aarch64-dev-release.example.json").read_text() + ) + errors = list(Draft202012Validator(brs_schema).iter_errors(example)) + assert errors == [], [e.message for e in errors]