From f5ed01d3ff465616b451bdcfd7df62ed60136ac2 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Fri, 6 Nov 2026 19:53:41 -0500 Subject: [PATCH] Build reproducible rescue images in isolated QEMU workers --- docs/rescue-image.md | 251 ++++ misc-util/rescue-image-build | 1267 +++++++++++++++++ misc-util/rescue-image.inputs.json | 93 ++ misc-util/rescue-services/mdevd | 4 + misc-util/rescue-services/mdevd-coldplug | 3 + misc-util/rescue-services/mount-dev | 2 + misc-util/rescue-services/mount-devpts | 2 + misc-util/rescue-services/mount-proc | 2 + misc-util/rescue-services/mount-runtime | 2 + misc-util/rescue-services/mount-shm | 2 + misc-util/rescue-services/mount-sys | 2 + misc-util/rescue-services/mount-tmp | 2 + misc-util/rescue-services/mount-var-log | 2 + misc-util/rescue-services/mount-var-tmp | 2 + misc-util/rescue-services/rescue-shell | 4 + .../core/linux/mere-x86_64.config.fragment | 8 + recipes/core/linux/recipe.kdl | 6 +- 17 files changed, 1651 insertions(+), 3 deletions(-) create mode 100644 docs/rescue-image.md create mode 100755 misc-util/rescue-image-build create mode 100644 misc-util/rescue-image.inputs.json create mode 100644 misc-util/rescue-services/mdevd create mode 100644 misc-util/rescue-services/mdevd-coldplug create mode 100644 misc-util/rescue-services/mount-dev create mode 100644 misc-util/rescue-services/mount-devpts create mode 100644 misc-util/rescue-services/mount-proc create mode 100644 misc-util/rescue-services/mount-runtime create mode 100644 misc-util/rescue-services/mount-shm create mode 100644 misc-util/rescue-services/mount-sys create mode 100644 misc-util/rescue-services/mount-tmp create mode 100644 misc-util/rescue-services/mount-var-log create mode 100644 misc-util/rescue-services/mount-var-tmp create mode 100644 misc-util/rescue-services/rescue-shell diff --git a/docs/rescue-image.md b/docs/rescue-image.md new file mode 100644 index 00000000..1bd788e1 --- /dev/null +++ b/docs/rescue-image.md @@ -0,0 +1,251 @@ +# Rescue image build pipeline + +`misc-util/rescue-image-build` is the #648 host-side assembler. It consumes +staged Mere package trees, a staged x86_64 kernel, and the Limine EFI output. It +produces a 2 GiB GPT raw rescue image, a separate installed-system fixture, a +dirty-journal fixture, and `manifest.json`. + +The assembler is deliberately fail-closed. It requires the fixed inputs in +`misc-util/rescue-image.inputs.json`, root for loop/mount operations, the pinned +image tools, and two clean-build comparisons. A single build cannot be reported +as reproducible. + +The selected Mere version is `0.23.0` throughout the tool profile and build +inputs. The pinned x86_64 Mere binary SHA-256 is +`3250c04b7f288056f5db8860a644a17895a7dc6398ddc961e1316bfe928ea631`. +The assembler uses `parted` to inspect the deterministic GPT after +writing fixed GUIDs, and uses `mere dev hash` for BLAKE3 digests. It does not +require `sgdisk` or a separate `blake3` executable. + +## Package preparation + +Use the existing Swamp workspace at `/home/jeremy/swamp-workspace` as +`SWAMP_REPO`. Create dedicated behavior definitions with separate roots. Do not +reuse an unrelated shell root, and do not run `mere-dev` inside `mere-shell`: + +```sh +export SWAMP_REPO=/home/jeremy/swamp-workspace +swamp behavior create @jeremy/mere-dev mere-rescue-dev-023 \ + --repo-dir "$SWAMP_REPO" \ + --global-arg mereVersion=0.23.0 \ + --global-arg mereRoot="$PWD/.swamp/mere-rescue-dev-023/root" +swamp behavior create @jeremy/mere-shell mere-rescue-shell-023 \ + --repo-dir "$SWAMP_REPO" \ + --global-arg mereVersion=0.23.0 \ + --global-arg mereRoot="$PWD/.swamp/mere-rescue-dev-023/root" +swamp behavior create @jeremy/mere-shell mere-rescue-tools-023-hoststore \ + --repo-dir "$SWAMP_REPO" \ + --global-arg mereVersion=0.23.0 \ + --global-arg mereRoot=/ \ + --global-arg useHostStore=true +``` + +Build the non-kernel recipes through `@jeremy/mere-dev`, which invokes +`mere dev build` directly in its isolated root. The top-level command uses the +existing pinned Linux package instead of rebuilding Linux: + +```sh +for recipe in \ + recipes/core/busybox/recipe.kdl \ + recipes/core/dinit/recipe.kdl \ + recipes/core/e2fsprogs/recipe.kdl \ + recipes/core/limine/recipe.kdl \ + recipes/core/mdevd/recipe.kdl \ + recipes/core/parted/recipe.kdl +do + swamp behavior run mere-rescue-dev-023 build \ + --repo-dir "$SWAMP_REPO" --input "recipe=$PWD/$recipe" +done +``` + +### Isolated image-tool profile + +The pinned tool profile uses `busybox`, `parted`, and `e2fsprogs`. Busybox +supplies the `mkfs.vfat` and `losetup` applets. E2fsprogs supplies `mkfs.ext4`, +`tune2fs`, and `debugfs`. The configured Mere repository does not currently +provide `dosfstools` or `util-linux`, so those package names are not claimed. + +Run the profile smoke with an explicit package list and exact argv: + +```sh +misc-util/rescue-image-build --tool-profile-smoke \ + --tool-shell-behavior mere-rescue-tools-023-hoststore \ + --swamp-repo-dir "$SWAMP_REPO" +``` + +The `useHostStore=true` binding must use `mereRoot=/` (an empty +`mereRoot` is also accepted by `@jeremy/mere-shell` 2026.11.04.2). This is +different from the isolated `mere-rescue-tools-023` binding, which keeps its +own dedicated root for recipe and development work. The published extension +maps `/` to the host Mere tree and exposes its existing `/mere/store`; it does +not create the legacy self-referential store symlink. The host Mere store must +already be initialized and readable by the execution worker. + +The smoke runner checks each returned resource's `success`, `exitCode`, and +reported Mere version. `tune2fs -V` prints its version but exits with status 1 +in the pinned e2fsprogs package, so that one command records +`expected_exit_code: 1` rather than pretending that a nonzero result succeeded. +A Swamp wrapper can report `status: succeeded` while this resource still has +`success: false`; inspect the resource fields before treating smoke as valid. +On this host, the current Swamp worker is rootless and cannot mount the host +Mere tree for namespace execution, so the published binding reaches Mere 0.23.0 +but the namespace launcher returns `/bin/sh` not found. The profile itself is +valid: Mere's profile is a symlink-tree projection under +`/mere/profiles//root`, and its package links intentionally target +logical `/mere/store/...`. With host root `/`, those links resolve through the +host Mere store. This is an execution-environment blocker, not evidence that a +guessed store path or shell wrapper is safe. The direct privileged Mere check +remains separate evidence for package contents. + +### Direct named profile validation + +For host-side validation, create and apply the named profile with the pinned +Mere 0.23 binary in a mount-capable environment: + +```sh +mere --root / profile create mere-rescue-tools-023-hoststore +mere --root / profile apply "$PWD/.swamp/mere-shell/profiles/profile.kdl" \ + --profile mere-rescue-tools-023-hoststore +mere --root / profile packages --profile mere-rescue-tools-023-hoststore +``` + +Mere records the active profile root at +`/mere/profiles/mere-rescue-tools-023-hoststore/root`. Set +`TOOL_PROFILE_ROOT` to that recorded root and invoke the required binaries by +their profile-root paths when testing the host-side boundary, for example +`$TOOL_PROFILE_ROOT/sbin/parted`, `$TOOL_PROFILE_ROOT/sbin/mkfs.vfat`, +`$TOOL_PROFILE_ROOT/sbin/mkfs.ext4`, `$TOOL_PROFILE_ROOT/sbin/losetup`, +`$TOOL_PROFILE_ROOT/sbin/tune2fs`, and `$TOOL_PROFILE_ROOT/sbin/debugfs`. +This uses the published symlink-tree contract directly, without a shell wrapper +or guessed store path. Loop setup and mount operations still run as explicit +host operations under root. + +The assembler receives `--tool-profile-root` explicitly and fails closed if +the root lacks `profile.kdl`, `realization.v1`, the pinned package names, or any +required profile executable. The assembler records the profile root and the +resolved profile executable paths in `manifest.json`. `mount`, `umount`, +`mere`, and `tar` remain deliberate host-side dependencies; they are not +looked up in the Mere profile. + +The top-level `--build --build-packages` path performs package staging before +assembly. It consumes prebuilt non-kernel package outputs through +`@jeremy/mere-dev`, uses the pinned Linux `6.18.62-1` package after auditing +its shipped `boot/config`, stages the rescue root, and writes the package +manifest. `--compare` additionally stages the installed-system fixture, creates +the fixture ESP, and performs the slower internal clean-build and dirty-journal +audit. The path does not compile Linux or other packages in the rescue worker. +The kernel audit follows the real 6.18.62 configuration symbols; x86_64 also +enables the KVM core and Intel/AMD backends as modules. + +OVMF preparation is pinned to Tianocore `edk2-stable202608`, commit +`2970e5699ba6267f3384ffab20f96647578aebc8`, from the codeload source archive +whose SHA-256 is +`bac40812213576c3025cf780c33d882f9e087a44201b1cd28a07f6d1eb54b76a`. +The pipeline verifies that digest before recursive checkout, builds +`OvmfPkg/OvmfPkgX64.dsc` with `CLANGDWARF`, `RELEASE`, `NETWORK_ENABLE=FALSE`, +`SECURE_BOOT_ENABLE=FALSE`, and `SMM_REQUIRE=FALSE`, and copies a fresh +`OVMF_VARS.fd` template with deterministic timestamps. ACPICA `iasl` is built +from the separately pinned ACPICA source archive. The verified codeload +archive is retained and SHA-256 checked as source provenance, but it is not +used as the build tree because it omits required submodules. The pipeline +that commit detached, initializes every submodule recursively (including +submodules marked `update=none`), and verifies every checked-out gitlink before +BaseTools runs. The firmware manifest records the git URL, superproject commit, +archive digest, and every verified submodule commit. It does not silently use +unpinned submodule heads or host firmware. + +## Assembly inputs and command + +The assembler installs the dinit service profile from +`misc-util/rescue-services` under `/etc/dinit.d/`, and audits `/boot/config`. +The OVMF build uses a dedicated Mere 0.23 profile containing `llvm`, `nasm`, +`flex`, `bison`, `make`, `m4`, and `util-linux-libs-dev`. Its recorded root is +`/mere/profiles/mere-rescue-ovmf-023/root`; the active package versions and +archive hashes are written into the firmware manifest. The profile supplies +Clang/Clang++, NASM, Make, Flex, Bison, m4, Python, and the UUID development +headers. ACPICA iasl is built from +the separately pinned ACPICA source archive above. + +```sh +misc-util/rescue-image-build --build \ + --compare \ + --build-packages \ + --mere-dev-behavior mere-rescue-dev-023 \ + --swamp-repo-dir "$SWAMP_REPO" \ + --tool-profile-root "$TOOL_PROFILE_ROOT" \ + --ovmf-tool-profile-root /mere/profiles/mere-rescue-ovmf-023/root \ + --output-dir out/rescue-image +``` + +A representative package manifest has this shape. The package list must include +every image package and every tool-profile package: + +```json +{ + "mere": { + "version": "0.23.0", + "binary_blake3": "", + "binary_sha256": "3250c04b7f288056f5db8860a644a17895a7dc6398ddc961e1316bfe928ea631", + "repository_snapshot": "" + }, + "packages": [ + {"name": "dinit", "version": "", "archive_blake3": ""} + ], + "providers": { + "/bin/busybox": "busybox", + "/boot/vmlinux": "linux", + "/boot/config": "linux" + }, + "tool_profile_packages": [ + {"name": "busybox", "version": "1.37.0-10", "archive_blake3": ""} + ] +} +``` + +## Outputs and acceptance handoff + +The output directory contains: + +- `mere-rescue.raw`: GPT image with a 256 MiB FAT32 ESP followed by ext4. +- `mere-system-fixture.raw`: separate GPT installed-system fixture. +- `mere-system-fixture-dirty-journal.raw`: fixture copied while its root was + mounted and a known marker was fsynced, before clean unmount. +- `manifest.json`: fixed identifiers, package inputs, tool versions, commands, + BLAKE3 image/tree digests, and both clean-build comparisons. + +The rescue ESP layout is exactly `/EFI/BOOT/BOOTX64.EFI`, +`/EFI/BOOT/limine.conf`, and `/EFI/BOOT/vmlinuz`. The generated config uses the +fixed root PARTUUID, `rootwait`, `rootflags=ro,noload`, `init=/sbin/dinit`, and +serial Limine branding. No BIOS, hybrid, direct-kernel, squashfs, or initramfs +artifact is assembled. + +The rescue profile mounts only virtual filesystems, starts mdevd before +coldplug, and prints `MERE-LINUX-RESCUE-READY`. It has no root remount or +attached-disk discovery service. + +The assembler does not run QEMU or inspect OVMF. #649 consumes the pinned OVMF +code and fresh writable NVRAM provenance recorded alongside this manifest. #650 +uses the clean and dirty fixture outputs for offline hash-preservation +acceptance. + +## Local validation and dependencies + +Repository-only validation does not need image privileges or host image tools: + +```sh +misc-util/rescue-image-build --check +python3 -m py_compile misc-util/rescue-image-build +mere dev validate recipes/core/linux/recipe.kdl +``` + +Full assembly additionally requires the validated Mere profile root above and +host `mount`, `umount`, `mere`, and `tar`. The assembler uses profile-root +executables for `parted`, `mkfs.fat`/`mkfs.vfat`, `mkfs.ext4`, `losetup`, +`tune2fs`, and `debugfs`. It performs loop setup and mounts as root. It does not +silently fall back to host PATH versions when `--tool-profile-root` is supplied, +and the option is required for `--build`. Profile smoke output and direct +profile-root checks prove package availability; they do not replace the root +privilege required for image assembly. +No output is a reproducibility claim until `--compare` writes +`reproducibility.clean_builds_compared: true` and both byte-identity fields are +true in `manifest.json`. diff --git a/misc-util/rescue-image-build b/misc-util/rescue-image-build new file mode 100755 index 00000000..bb7355dd --- /dev/null +++ b/misc-util/rescue-image-build @@ -0,0 +1,1267 @@ +#!/usr/bin/env python3 +"""Build the pinned, no-initramfs x86_64 UEFI rescue image and fixtures.""" +from __future__ import annotations + +import argparse +import binascii +import datetime +import hashlib +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +from typing import Any, NoReturn +import uuid +import re +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +INPUTS = ROOT / "misc-util" / "rescue-image.inputs.json" +IMAGE_TOOLS = ( + "parted", "mkfs.fat", "mkfs.ext4", "losetup", "mount", "umount", + "tune2fs", "debugfs", "mere", +) +PROFILE_IMAGE_TOOLS = ("parted", "mkfs.fat", "mkfs.ext4", "losetup", "tune2fs", "debugfs") +PROFILE_TOOL_CANDIDATES = { + "parted": ("sbin/parted",), + "mkfs.fat": ("sbin/mkfs.fat", "sbin/mkfs.vfat"), + "mkfs.ext4": ("sbin/mkfs.ext4",), + "losetup": ("sbin/losetup",), + "tune2fs": ("sbin/tune2fs",), + "debugfs": ("sbin/debugfs",), +} +REQUIRED_SERVICE_FILES = ( + "mount-dev", "mount-proc", "mount-sys", "mount-devpts", "mount-shm", + "mount-runtime", "mount-tmp", "mount-var-log", "mount-var-tmp", "mdevd", + "mdevd-coldplug", "rescue-shell", +) +COMMAND_LOG: list[list[str]] = [] +RESOLVED_TOOLS: dict[str, str] = {} + + +def die(message: str) -> NoReturn: + print(f"rescue-image-build: error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def command(argv: list[str], *, cwd: Path | None = None, capture: bool = False) -> str: + print("+", " ".join(json.dumps(part) for part in argv), file=sys.stderr) + COMMAND_LOG.append(argv[:]) + result = subprocess.run( + argv, + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + if result.returncode: + if capture and result.stderr: + print(result.stderr, file=sys.stderr, end="") + die(f"command exited {result.returncode}: {argv[0]}") + return result.stdout if capture else "" + + +def digest(path: Path) -> str: + """Use Mere's available BLAKE3-equivalent instead of a host b3sum tool.""" + output = command([executable("mere"), "dev", "hash", str(path)], capture=True) + for token in output.split(): + if len(token) == 64 and all(char in "0123456789abcdef" for char in token.lower()): + return token.lower() + die(f"mere dev hash returned no BLAKE3 digest for {path}") + + +def tree_digest(path: Path, epoch: int) -> str: + with tempfile.NamedTemporaryFile(prefix="mere-tree-", suffix=".tar", delete=False) as stream: + archive = Path(stream.name) + try: + with tarfile.open(archive, mode="w", format=tarfile.USTAR_FORMAT, dereference=False) as tar: + entries = [path, *sorted(path.rglob("*"), key=lambda item: item.relative_to(path).as_posix())] + for item in entries: + relative = "." if item == path else f"./{item.relative_to(path).as_posix()}" + info = tar.gettarinfo(str(item), arcname=relative) + info.mtime = epoch + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + if info.isreg(): + with item.open("rb") as source: + tar.addfile(info, source) + else: + tar.addfile(info) + return digest(archive) + finally: + archive.unlink(missing_ok=True) + + +def check_inputs(inputs: dict[str, Any]) -> None: + image = inputs["image"] + if image["size_bytes"] != 2 * 1024 * 1024 * 1024: + die("image size must remain exactly 2 GiB") + if image["esp_size_bytes"] != 256 * 1024 * 1024: + die("ESP size must remain exactly 256 MiB") + for key in ("disk_guid", "esp_guid", "root_guid", "esp_type_guid", "root_type_guid"): + try: + uuid.UUID(image[key]) + except (ValueError, KeyError): + die(f"invalid fixed GUID in image inputs: {key}") + if inputs["toolchain"]["source_date_epoch"] <= 0: + die("SOURCE_DATE_EPOCH must be positive") + + +def executable(name: str) -> str: + return RESOLVED_TOOLS.get(name, name) + + +def validate_profile_root(profile_root: Path, inputs: dict[str, Any]) -> None: + if not profile_root.is_dir(): + die(f"tool profile root does not exist: {profile_root}") + profile = profile_root / "profile.kdl" + realization = profile_root / "realization.v1" + if not profile.is_file() or not realization.is_file(): + die("tool profile root must contain profile.kdl and realization.v1") + profile_text = profile.read_text(encoding="utf-8", errors="replace") + missing_packages = [ + package for package in inputs["tool_profile"]["packages"] + if f'package "{package}"' not in profile_text + ] + if missing_packages: + die("tool profile root is missing pinned packages: " + ", ".join(missing_packages)) + + +def check_environment(*, profile_root: Path, inputs: dict[str, Any], include_swamp: bool = False) -> None: + missing: list[str] = [] + RESOLVED_TOOLS.clear() + validate_profile_root(profile_root, inputs) + for logical in PROFILE_IMAGE_TOOLS: + candidates = tuple(profile_root / relative for relative in PROFILE_TOOL_CANDIDATES[logical]) + selected = next((candidate for candidate in candidates if candidate.is_file() and os.access(candidate, os.X_OK)), None) + if selected is None: + missing.append(" or ".join(str(candidate) for candidate in candidates)) + else: + RESOLVED_TOOLS[logical] = str(selected) + for logical in ("mount", "umount", "mere"): + selected = shutil.which(logical) + if selected is None: + missing.append(logical) + else: + RESOLVED_TOOLS[logical] = selected + if include_swamp and shutil.which("swamp") is None: + missing.append("swamp") + if missing: + die("missing required image tools: " + ", ".join(missing)) + if os.geteuid() != 0: + die("image assembly requires root for loop and mount operations") + + +def tool_versions() -> dict[str, str]: + versions: dict[str, str] = {} + for tool in IMAGE_TOOLS: + argv = [executable(tool), "--version"] + COMMAND_LOG.append(argv[:]) + result = subprocess.run(argv, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = (result.stdout or result.stderr).splitlines() + if result.returncode and not output: + die(f"cannot record version for image tool {tool}") + versions[tool] = output[0] if output else "unknown" + return versions + + +def check_tree(root: Path, *, image: bool) -> None: + if not root.is_dir(): + die(f"staged root does not exist: {root}") + if image: + config = root / "boot" / "config" + if not config.is_file(): + die("staged rescue root lacks boot/config for the kernel audit") + elif not all((root / path).is_file() for path in ("boot/vmlinux", "etc/fstab", "etc/hostname")): + die("fixture root must contain /boot/vmlinux, /etc/fstab, and /etc/hostname") + forbidden = [] + for item in root.rglob("*"): + if item.is_file() and item.name.lower() in ("initrd", "initrd.gz", "initramfs", "initramfs.gz"): + forbidden.append(str(item.relative_to(root))) + if item.is_file() and item.suffix in (".cpio", ".cpio.gz", ".cpio.xz", ".cpio.zst"): + forbidden.append(str(item.relative_to(root))) + if forbidden: + die("staged root contains early-userspace archives: " + ", ".join(forbidden)) + if not image: + return + text = (root / "boot" / "config").read_text(encoding="utf-8", errors="replace") + required = { + "CONFIG_EFI=y", "CONFIG_EFI_STUB=y", "CONFIG_EFI_PARTITION=y", + "CONFIG_SCSI=y", "CONFIG_BLK_DEV_SD=y", "CONFIG_USB_STORAGE=y", + "CONFIG_USB_XHCI_HCD=y", "CONFIG_USB_EHCI_HCD=y", "CONFIG_EXT4_FS=y", + "CONFIG_VFAT_FS=y", "CONFIG_DEVTMPFS=y", "CONFIG_DEVTMPFS_MOUNT=y", + "CONFIG_PROC_FS=y", "CONFIG_SYSFS=y", "CONFIG_TMPFS=y", + "CONFIG_SERIAL_8250_CONSOLE=y", "CONFIG_VT=y", "CONFIG_FRAMEBUFFER_CONSOLE=y", + "CONFIG_INITRAMFS_SOURCE=\"\"", + } + missing = sorted(option for option in required if option not in text.splitlines()) + if missing: + die("kernel config is missing required built-ins: " + ", ".join(missing)) + if "CONFIG_INITRAMFS_SOURCE=" in text and 'CONFIG_INITRAMFS_SOURCE=""' not in text: + die("kernel config embeds an initramfs") + + +def stage_rescue_root(source: Path, destination: Path, epoch: int) -> None: + shutil.copytree(source, destination, symlinks=True) + services = ROOT / "misc-util" / "rescue-services" + target = destination / "etc" / "dinit.d" + target.mkdir(parents=True, exist_ok=True) + for path in ("dev/pts", "dev/shm", "proc", "sys", "run", "tmp", "var/log", "var/tmp"): + (destination / path).mkdir(parents=True, exist_ok=True) + for name in REQUIRED_SERVICE_FILES: + shutil.copy2(services / name, target / name) + for path in [destination, *destination.rglob("*")]: + os.utime(path, (epoch, epoch), follow_symlinks=False) + + +def write_limine_config(path: Path, root_guid: str) -> None: + path.write_text( + """timeout: 0\ngraphics: no\nserial: yes\ninterface_branding: MERE-LINUX-UEFI-LIMINE\n/Mere Linux Rescue\n protocol: linux\n path: boot:///EFI/BOOT/vmlinuz\n cmdline: root=PARTUUID={root_guid} rootfstype=ext4 rootflags=ro,noload rootwait init=/sbin/dinit console=tty0 console=ttyS0,115200n8 loglevel=4\n""".format(root_guid=root_guid), + encoding="ascii", + ) + + +def gpt_guid(value: str) -> bytes: + return uuid.UUID(value).bytes_le + + +def write_gpt(image: Path, inputs: dict[str, Any], ids: dict[str, str]) -> None: + """Write deterministic GPT structures, then use parted to inspect them. + + parted cannot set GUIDs portably, so fixed GPT identifiers are written here; + parted remains the required partition-table inspection tool. + """ + spec = inputs["image"] + sector = spec["sector_size"] + sectors = spec["size_bytes"] // sector + entry_count, entry_size = 128, 128 + entry_sectors = entry_count * entry_size // sector + first_usable, last_usable = 2 + entry_sectors, sectors - 2 - entry_sectors + esp_start = spec["esp_start_lba"] + esp_end = esp_start + spec["esp_size_bytes"] // sector - 1 + root_start = esp_end + 1 + entries = bytearray(entry_count * entry_size) + + def entry(index: int, type_guid: str, part_guid: str, start: int, end: int, name: str) -> None: + offset = index * entry_size + entries[offset:offset + 16] = gpt_guid(type_guid) + entries[offset + 16:offset + 32] = gpt_guid(part_guid) + struct.pack_into(" bytes: + raw = bytearray(sector) + raw[0:8] = b"EFI PART" + struct.pack_into(" str: + with tempfile.TemporaryDirectory(prefix="mere-esp-tree-") as directory: + tree = Path(directory) + if not fixture: + target = tree / "EFI" / "BOOT" + target.mkdir(parents=True) + shutil.copy2(limine, target / "BOOTX64.EFI") + shutil.copy2(kernel, target / "vmlinuz") + write_limine_config(target / "limine.conf", root_guid) + if esp_tree: + for item in esp_tree.rglob("*"): + if item.is_file(): + target = tree / item.relative_to(esp_tree) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + return tree_digest(tree, inputs["toolchain"]["source_date_epoch"]) + + +def copy_sparse_region(source: Path, destination: Path, offset: int, size: int) -> None: + """Copy a filesystem image into a sparse raw-image region.""" + with source.open("rb") as source_stream, destination.open("r+b") as destination_stream: + destination_stream.seek(offset) + remaining = size + while remaining: + chunk = source_stream.read(min(1024 * 1024, remaining)) + if not chunk: + die(f"filesystem image ended before expected size: {source}") + if any(chunk): + destination_stream.write(chunk) + else: + destination_stream.seek(len(chunk), os.SEEK_CUR) + remaining -= len(chunk) + + +def format_esp(output: Path, start: int, esp_size: int, ids: dict[str, str], + inputs: dict[str, Any]) -> None: + """Format the validated BusyBox FAT applet on an exact-size temporary file.""" + with tempfile.TemporaryDirectory(prefix="mere-rescue-esp-image-") as directory: + filesystem = Path(directory) / "esp.fat" + with filesystem.open("wb") as stream: + stream.truncate(esp_size) + command([executable("mkfs.fat"), "-n", ids.get("fat_label", "MERE-ESP"), + str(filesystem), str(esp_size // 1024)]) + with filesystem.open("r+b") as stream: + boot = stream.read(512) + if len(boot) != 512: + die("FAT formatter produced a short boot sector") + backup_sector = struct.unpack_from(" None: + """Normalize active FAT directory timestamps after the ESP is populated. + + The parser follows FAT32 directory cluster chains and only edits timestamp + fields in active short entries. Long-name entries, free entries, volume + labels, FAT tables, and file data remain unchanged. + """ + with image.open("r+b") as stream: + stream.seek(esp_start) + boot = stream.read(512) + if len(boot) != 512 or boot[510:512] != b"\x55\xaa": + die("ESP does not contain a valid FAT boot sector") + bytes_per_sector = struct.unpack_from(" image_end: + die("ESP FAT data region exceeds the ESP boundary") + fat_end = fat_offset + sectors_per_fat * bytes_per_sector + if fat_end > image_end: + die("ESP FAT table exceeds the ESP boundary") + + def cluster_offset(cluster: int) -> int: + if cluster < 2 or cluster >= root_cluster + cluster_count: + die(f"ESP FAT directory references invalid cluster {cluster}") + return data_offset + (cluster - 2) * cluster_size + + def next_cluster(cluster: int) -> int: + stream.seek(fat_offset + cluster * 4) + raw = stream.read(4) + if len(raw) != 4: + die("ESP FAT table ended while following a directory") + return struct.unpack(" list[int]: + chain: list[int] = [] + seen: set[int] = set() + cluster = start + while 2 <= cluster < 0x0ffffff8: + if cluster in seen: + die("ESP FAT directory cluster chain loops") + seen.add(cluster) + chain.append(cluster) + if len(chain) > cluster_count: + die("ESP FAT directory cluster chain is too long") + cluster = next_cluster(cluster) + if cluster not in (0, 1) and cluster < 0x0ffffff8: + die(f"ESP FAT directory chain has invalid terminator {cluster}") + return chain + + moment = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc) + fat_date = ((moment.year - 1980) << 9) | (moment.month << 5) | moment.day + fat_time = (moment.hour << 11) | (moment.minute << 5) | (moment.second // 2) + fat_tenth = (moment.second % 2) * 100 + visited_directories: set[int] = set() + + def normalize_directory(start: int) -> None: + if start in visited_directories: + return + visited_directories.add(start) + for cluster in cluster_chain(start): + offset = cluster_offset(cluster) + stream.seek(offset) + contents = bytearray(stream.read(cluster_size)) + if len(contents) != cluster_size: + die("ESP FAT directory cluster is truncated") + changed = False + child_directories: list[int] = [] + for entry_offset in range(0, cluster_size, 32): + entry = contents[entry_offset:entry_offset + 32] + first = entry[0] + if first == 0x00: + break + if first == 0xe5 or entry[11] == 0x0f: + continue + attributes = entry[11] + if attributes & 0xc0: + die("ESP FAT directory contains an invalid attribute byte") + if attributes & 0x08: + continue + struct.pack_into("= 2: + child_directories.append(child) + if changed: + stream.seek(offset) + stream.write(contents) + for child in child_directories: + normalize_directory(child) + + normalize_directory(root_cluster) + + +def build_one(output: Path, root_tree: Path, kernel: Path, limine: Path, + esp_tree: Path | None, inputs: dict[str, Any], *, fixture: bool) -> dict[str, Any]: + image = inputs["image"] + ids = inputs["fixture"] if fixture else image + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as stream: + stream.truncate(image["size_bytes"]) + write_gpt(output, inputs, ids) + start = image["esp_start_lba"] * image["sector_size"] + esp_size = image["esp_size_bytes"] + root_start = start + esp_size + format_esp(output, start, esp_size, ids, inputs) + root_size = image["size_bytes"] - root_start + with tempfile.TemporaryDirectory(prefix="mere-rescue-root-image-") as directory: + filesystem = Path(directory) / "root.ext4" + with filesystem.open("wb") as stream: + stream.truncate(root_size) + root_device = command([executable("losetup"), "-f"], capture=True).strip() + command([executable("losetup"), "-o", "0", root_device, str(filesystem)]) + try: + command([executable("mkfs.ext4"), "-q", "-U", ids["ext4_uuid"], "-E", f"hash_seed={ids.get('ext4_hash_seed', image['ext4_hash_seed'])}", + "-O", ",".join(image["ext4_features"]), "-T", "default", "-d", str(root_tree), root_device]) + check_date = datetime.datetime.fromtimestamp(inputs["toolchain"]["source_date_epoch"], datetime.timezone.utc).strftime("%Y%m%d") + command([executable("tune2fs"), "-T", check_date, root_device]) + command([executable("debugfs"), "-w", "-R", f"set_super_value journal_uuid {ids.get('ext4_journal_uuid', image['ext4_journal_uuid'])}", root_device]) + finally: + command([executable("losetup"), "-d", root_device]) + copy_sparse_region(filesystem, output, root_start, root_size) + with tempfile.TemporaryDirectory(prefix="mere-rescue-esp-") as mounted: + mountpoint = Path(mounted) + command([executable("mount"), "-o", f"loop,offset={start},sizelimit={esp_size}", str(output), str(mountpoint)]) + try: + if not fixture: + target = mountpoint / "EFI" / "BOOT" + target.mkdir(parents=True) + shutil.copy2(limine, target / "BOOTX64.EFI") + shutil.copy2(kernel, target / "vmlinuz") + write_limine_config(target / "limine.conf", ids["root_guid"]) + if esp_tree: + for item in esp_tree.rglob("*"): + if item.is_file(): + target = mountpoint / item.relative_to(esp_tree) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + for item in mountpoint.rglob("*"): + if item.is_file(): + os.utime(item, (inputs["toolchain"]["source_date_epoch"],) * 2) + finally: + command([executable("umount"), "-l", str(mountpoint)]) + normalize_fat_timestamps(output, start, esp_size, inputs["toolchain"]["source_date_epoch"]) + return { + "path": str(output), "blake3": digest(output), + "esp_contents_blake3": esp_digest(kernel, limine, esp_tree, inputs, ids["root_guid"], fixture), + "limine_efi_blake3": digest(limine), "kernel_blake3": digest(kernel), + "root_tree_blake3": tree_digest(root_tree, inputs["toolchain"]["source_date_epoch"]), + } + + +def swamp_repo_args(args: argparse.Namespace) -> list[str]: + repo_dir = args.swamp_repo_dir or os.environ.get("SWAMP_REPO_DIR") + if not repo_dir: + die("Swamp operations require --swamp-repo-dir or SWAMP_REPO_DIR") + result = ["--repo-dir", str(repo_dir)] + token_file = args.swamp_server_token_file or os.environ.get("SWAMP_SERVER_TOKEN_FILE") + if token_file: + result.extend(["--token-file", str(token_file)]) + server_url = os.environ.get("SWAMP_SERVER_URL") or os.environ.get("SWAMP_SERVE_URL") + if server_url: + result.extend(["--server", server_url]) + return result + + +def invoke_tool_profile(args: argparse.Namespace, inputs: dict[str, Any]) -> list[dict[str, Any]]: + if not args.tool_shell_behavior: + die("--tool-profile-smoke requires a pre-created @jeremy/mere-shell behavior") + packages = json.dumps(inputs["tool_profile"]["packages"], separators=(",", ":")) + observations: list[dict[str, Any]] = [] + for command_spec in inputs["tool_profile"]["commands"]: + argv = json.dumps(command_spec["argv"], separators=(",", ":")) + output = command(["swamp", "--json", "behavior", "run", args.tool_shell_behavior, "run", + *swamp_repo_args(args), "--input", f"packages:json={packages}", "--input", f"argv:json={argv}"], capture=True) + try: + result = json.loads(output) + attributes = result["dataArtifacts"][0]["attributes"] + except (json.JSONDecodeError, KeyError, IndexError, TypeError) as error: + die(f"tool profile smoke returned malformed Swamp output: {error}") + expected_exit_code = command_spec.get("expected_exit_code", 0) + success = bool(attributes.get("success")) + exit_code = attributes.get("exitCode") + observations.append({ + "name": command_spec["name"], + "success": success, + "exitCode": exit_code, + "expectedExitCode": expected_exit_code, + }) + if (success and exit_code != 0) or (not success and exit_code != expected_exit_code): + die(f"tool profile command failed: {command_spec['name']}: {attributes.get('stderr', '')}") + if attributes.get("mereVersion") != inputs["tool_profile"]["mere_version"]: + die("tool profile reported an unexpected Mere version") + return observations + + +def invoke_mere_dev(args: argparse.Namespace, inputs: dict[str, Any]) -> None: + if not args.mere_dev_behavior: + die("--build-packages requires a pre-created pinned @jeremy/mere-dev behavior") + for package in inputs["packages"]: + if package["name"] == "linux": + continue + command(["swamp", "behavior", "run", args.mere_dev_behavior, "build", + *swamp_repo_args(args), "--input", f"recipe={ROOT / package['recipe']}"]) + + +def package_build_output(dev_root: Path, name: str) -> tuple[Path, Path, str]: + candidates = sorted(dev_root.glob(f"mere/dev/build/{name}-*/pkg/{name}")) + if not candidates: + die(f"Mere dev build produced no package tree for {name} under {dev_root}") + package_root = candidates[-1] + workspace = package_root.parent.parent + archives = sorted((workspace / "pkg").glob(f"{name}-*.pkg.tar.zst")) + if not archives: + die(f"Mere dev build produced no package archive for {name}") + report = workspace / "build-report.kdl" + version_match = re.search(r'\n version "([^"]+)"', report.read_text(encoding="utf-8")) if report.is_file() else None + release_match = re.search(r"\n release (\d+)", report.read_text(encoding="utf-8")) if report.is_file() else None + version = version_match.group(1) if version_match else "unknown" + if release_match: + version += f"-{release_match.group(1)}" + return package_root, archives[-1], version + + +def extract_kernel_archive(archive: Path, destination: Path, inputs: dict[str, Any]) -> None: + destination.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + ["zstd", "-dc", str(archive)], stdout=subprocess.PIPE, check=False, + ) + if result.returncode: + die(f"cannot decompress pinned kernel package: {archive}") + extractor = subprocess.run(["tar", "-x", "-f", "-", "-C", str(destination)], input=result.stdout, check=False) + if extractor.returncode: + die(f"cannot extract pinned kernel package: {archive}") + check_tree(destination, image=True) + + +def find_existing_kernel( + inputs: dict[str, Any], + destination: Path, + dev_root: Path, + linux_package: Path | None = None, +) -> tuple[Path, Path, str]: + version = inputs["toolchain"]["linux_version"] + if linux_package is not None: + if not linux_package.is_file(): + die(f"pinned Linux package is unavailable: {linux_package}") + if not linux_package.name.startswith(f"linux-{version}-"): + die(f"pinned Linux package does not match Linux {version}: {linux_package.name}") + candidate = destination / "kernel-candidate" + shutil.rmtree(candidate, ignore_errors=True) + extract_kernel_archive(linux_package, candidate, inputs) + return candidate, linux_package, version + built_roots = sorted((dev_root / "mere/dev/build").glob(f"linux-{version}-*/pkg/linux")) + if built_roots: + package_root = built_roots[-1] + workspace = package_root.parent.parent + archives = sorted((workspace / "pkg").glob(f"linux-{version}-*.pkg.tar.zst")) + if not archives: + die(f"Mere dev Linux build produced no package archive under {workspace}") + archive = archives[-1] + candidate = destination / "kernel-candidate" + shutil.rmtree(candidate, ignore_errors=True) + extract_kernel_archive(archive, candidate, inputs) + return candidate, archive, version + candidates = sorted( + (dev_root / "mere/cache/packages").glob( + f"linux-{version}-*-x86_64-*.pkg.tar.zst" + ) + ) + if not candidates: + die( + f"pinned Linux {version} package is unavailable under " + f"{dev_root / 'mere/cache/packages'}" + ) + for archive in candidates: + candidate = destination / "kernel-candidate" + shutil.rmtree(candidate, ignore_errors=True) + try: + extract_kernel_archive(archive, candidate, inputs) + return candidate, archive, version + except SystemExit: + continue + die(f"available Linux {version} packages lack the required rescue kernel configuration") + + +def merge_tree(source: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + target = destination / item.name + if item.is_dir() and not item.is_symlink(): + merge_tree(item, target) + elif item.is_file() or item.is_symlink(): + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() or target.is_symlink(): + target.unlink() + shutil.copy2(item, target, follow_symlinks=False) + + +def file_blake3(path: Path, mere_binary: Path, mere_root: Path) -> str: + result = subprocess.run( + [str(mere_binary), "--root", str(mere_root), "dev", "hash", str(path)], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + if result.returncode: + die(f"Mere hash failed for {path}: {result.stderr.strip()}") + for token in result.stdout.split(): + if len(token) == 64 and all(char in "0123456789abcdef" for char in token.lower()): + return token.lower() + die(f"Mere hash returned no BLAKE3 digest for {path}") + + +def package_provider_mapping(package_root: Path, package_name: str, providers: dict[str, str]) -> None: + for item in package_root.rglob("*"): + if item.name == ".mere" or ".mere" in item.parts or not (item.is_file() or item.is_symlink()): + continue + relative = "/" + item.relative_to(package_root).as_posix() + previous = providers.get(relative) + if previous and previous != package_name: + die(f"package provider conflict for {relative}: {previous} and {package_name}") + providers[relative] = package_name + + +def run_checked(argv: list[str], *, cwd: Path | None = None, capture: bool = False) -> str: + result = subprocess.run(argv, cwd=cwd, text=True, stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, check=False) + if result.returncode: + detail = result.stderr.strip() if capture and result.stderr else "" + die(f"command failed ({result.returncode}): {' '.join(argv)}{': ' + detail if detail else ''}") + return result.stdout if capture else "" + + +def gitlink_commits(repository: Path) -> dict[str, str]: + listing = run_checked(["git", "-C", str(repository), "ls-tree", "-r", "-z", "HEAD"], capture=True) + commits: dict[str, str] = {} + for record in listing.split("\0"): + if not record: + continue + header, relative = record.split("\t", 1) + mode, kind, commit = header.split(" ", 2) + if mode == "160000" and kind == "commit": + commits[relative] = commit + return commits + + +def verify_recursive_gitlinks(source: Path, required_paths: list[str]) -> dict[str, str]: + """Verify each required submodule against its superproject gitlink.""" + source = source.resolve() + commits = gitlink_commits(source) + verified: dict[str, str] = {} + for relative in required_paths: + commit = commits.get(relative) + if commit is None: + die(f"required OVMF submodule is not a tracked gitlink: {relative}") + child = source / relative + actual = run_checked(["git", "-C", str(child), "rev-parse", "HEAD"], capture=True).strip() + if actual != commit: + die(f"edk2 submodule commit mismatch at {relative}: got {actual}, want {commit}") + verified[relative] = commit + return dict(sorted(verified.items())) + + +def initialize_recursive_submodules(source: Path, required_paths: list[str]) -> None: + """Initialize only the pinned submodules required by the selected OVMF target.""" + source = source.resolve() + commits = gitlink_commits(source) + for relative in required_paths: + if relative not in commits: + die(f"required OVMF submodule is not a tracked gitlink: {relative}") + run_checked(["git", "-C", str(source), + "-c", f"submodule.{relative}.update=checkout", + "submodule", "update", "--init", relative]) + + +def prepare_recursive_edk2(source: Path, spec: dict[str, Any]) -> tuple[Path, dict[str, str]]: + source = source.resolve() + if source.exists(): + shutil.rmtree(source) + run_checked(["git", "clone", "--no-checkout", spec["git_url"], str(source)]) + run_checked(["git", "checkout", "--detach", spec["commit"]], cwd=source) + actual = run_checked(["git", "rev-parse", "HEAD"], cwd=source, capture=True).strip() + if actual != spec["commit"]: + die(f"edk2 superproject commit mismatch: got {actual}, want {spec['commit']}") + required_paths = spec.get("required_submodules", []) + if not required_paths or any(not isinstance(path, str) or not path for path in required_paths): + die("pinned OVMF required_submodules must be a non-empty list of paths") + initialize_recursive_submodules(source, required_paths) + return source, verify_recursive_gitlinks(source, required_paths) + + +def configure_edk2_conf(source: Path, spec: dict[str, Any]) -> None: + conf = source / "Conf" + conf.mkdir(exist_ok=True) + for name in ("target", "tools_def", "build_rule"): + template = source / "BaseTools/Conf" / f"{name}.template" + destination = conf / f"{name}.txt" + if not template.is_file(): + die(f"pinned edk2 source is missing BaseTools/Conf/{name}.template") + text = template.read_text(encoding="utf-8") + if name == "target": + values = { + "ACTIVE_PLATFORM": spec["build_target"], + "TARGET": "RELEASE", + "TARGET_ARCH": "X64", + "TOOL_CHAIN_TAG": spec["toolchain"], + } + for key, value in values.items(): + text, count = re.subn(rf"(?m)^{re.escape(key)}\s*=.*$", f"{key} = {value}", text) + if count != 1: + die(f"edk2 target template does not contain one {key} assignment") + destination.write_text(text, encoding="utf-8") + + +def prepare_ovmf(args: argparse.Namespace, inputs: dict[str, Any], generated: Path, + mere_binary: Path, dev_root: Path) -> dict[str, Any]: + generated = generated.resolve() + spec = inputs["ovmf"] + explicit = (args.ovmf_code, args.ovmf_source, args.ovmf_build, args.nvram_template) + if any(value is not None for value in explicit): + if not all(value is not None for value in explicit): + die("firmware manifest requires all of --ovmf-code, --ovmf-source, --ovmf-build, and --nvram-template") + if not args.ovmf_code.is_file() or not args.nvram_template.is_file(): + die("explicit OVMF code and NVRAM inputs must be existing files") + source = Path(args.ovmf_source) + if not source.is_dir(): + die("explicit OVMF source must be a recursive pinned edk2 checkout directory") + actual = run_checked(["git", "-C", str(source), "rev-parse", "HEAD"], capture=True).strip() + if actual != spec["commit"]: + die(f"explicit OVMF source commit mismatch: got {actual}, want {spec['commit']}") + required_paths = spec.get("required_submodules", []) + if not required_paths: + die("pinned OVMF required_submodules must be a non-empty list of paths") + submodule_commits = verify_recursive_gitlinks(source, required_paths) + expected_code = (source / spec["code_relative"]).resolve() + expected_nvram = (source / spec["vars_relative"]).resolve() + if args.ovmf_code.resolve() != expected_code or args.nvram_template.resolve() != expected_nvram: + die("explicit OVMF artifacts must be the outputs of the pinned recursive source checkout") + return { + "source": spec["source_url"], + "source_git_url": spec["git_url"], + "source_checkout": "recursive-git", + "source_archive": "", + "source_release": spec["release"], + "source_commit": spec["commit"], + "source_sha256": spec["source_sha256"], + "submodule_commits": submodule_commits, + "code_blake3": file_blake3(args.ovmf_code, mere_binary, dev_root), + "code_path": str(args.ovmf_code), + "nvram_template": str(args.nvram_template), + "nvram_template_blake3": file_blake3(args.nvram_template, mere_binary, dev_root), + } + archive = args.ovmf_source_archive or (generated / f"{spec['release']}.tar.gz") + profile_root = args.ovmf_tool_profile_root + if profile_root is None: + die("OVMF preparation requires --ovmf-tool-profile-root for the pinned Mere LLVM/nasm/flex/bison/make profile") + profile_tools = {} + for tool in ("clang", "clang++", "nasm", "make", "flex", "bison", "python3"): + candidates = (profile_root / "usr/bin" / tool, profile_root / "bin" / tool) + selected = next((candidate for candidate in candidates if candidate.is_file() and os.access(candidate, os.X_OK)), None) + if selected is None: + die(f"OVMF tool profile is missing executable: {tool}") + profile_tools[tool] = str(selected) + profile_text = (profile_root / "profile.kdl").read_text(encoding="utf-8", errors="replace") if (profile_root / "profile.kdl").is_file() else "" + for package in spec["tool_profile_packages"]: + if f'package "{package["name"]}"' not in profile_text: + die(f"OVMF tool profile is missing pinned package: {package['name']}") + + if not archive.is_file(): + try: + with urllib.request.urlopen(spec["source_url"], timeout=180) as response, archive.open("wb") as stream: + shutil.copyfileobj(response, stream) + except Exception as error: + die(f"cannot acquire pinned OVMF source {spec['release']}: {error}") + actual_sha256 = hashlib.sha256(archive.read_bytes()).hexdigest() + if actual_sha256 != spec["source_sha256"]: + die(f"pinned OVMF source SHA-256 mismatch: got {actual_sha256}, want {spec['source_sha256']}") + source, submodule_commits = prepare_recursive_edk2(generated / "ovmf-source", spec) + acpica_archive = args.acpica_source_archive or (generated / f"acpica-{spec['acpica_release']}.tar.gz") + if not acpica_archive.is_file(): + try: + with urllib.request.urlopen(spec["acpica_source_url"], timeout=180) as response, acpica_archive.open("wb") as stream: + shutil.copyfileobj(response, stream) + except Exception as error: + die(f"cannot acquire pinned ACPICA source {spec['acpica_release']}: {error}") + acpica_sha256 = hashlib.sha256(acpica_archive.read_bytes()).hexdigest() + if acpica_sha256 != spec["acpica_source_sha256"]: + die(f"pinned ACPICA source SHA-256 mismatch: got {acpica_sha256}, want {spec['acpica_source_sha256']}") + acpica_parent = generated / "acpica-source" + shutil.rmtree(acpica_parent, ignore_errors=True) + acpica_parent.mkdir() + try: + with tarfile.open(acpica_archive, "r:gz") as tar: + tar.extractall(acpica_parent, filter=lambda member, path: member) + except Exception as error: + die(f"cannot extract pinned ACPICA source: {error}") + acpica_dirs = [path for path in acpica_parent.iterdir() if path.is_dir()] + if len(acpica_dirs) != 1: + die("pinned ACPICA source archive did not contain one source directory") + acpica = acpica_dirs[0] + iasl_dir = acpica / "generate/unix/iasl" + iasl_dir.joinpath("obj").mkdir(parents=True, exist_ok=True) + (iasl_dir.parent / "bin").mkdir(parents=True, exist_ok=True) + iasl_env = os.environ.copy() + iasl_env["PATH"] = str(Path(profile_tools["clang"]).parent) + ":" + iasl_env.get("PATH", "") + iasl_env["BISON_PKGDATADIR"] = str(profile_root / "usr/share/bison") + iasl_env["M4"] = str(profile_root / "usr/bin/m4") + iasl_build = subprocess.run([profile_tools["make"], "-C", str(iasl_dir), + f"CC={profile_tools['clang']}", "NOWERROR=TRUE", + "CWARNINGFLAGS=-Wno-unknown-warning-option"], env=iasl_env, check=False) + if iasl_build.returncode: + die("pinned ACPICA iasl build failed") + iasl_path = acpica / "generate/unix/bin/iasl" + if not iasl_path.is_file(): + die("pinned ACPICA build did not produce iasl") + env = os.environ.copy() + env.update({"SOURCE_DATE_EPOCH": str(inputs["toolchain"]["source_date_epoch"]), "TZ": "UTC", + "LC_ALL": "C", "PYTHONHASHSEED": "0", "BUILD_NUMBER": "0", + "CC": profile_tools["clang"], "CXX": profile_tools["clang++"], + "CLANG_BIN": str(Path(profile_tools["clang"]).parent) + "/", + "IASL_PREFIX": str(iasl_path.parent) + "/"}) + env["PATH"] = str(Path(profile_tools["clang"]).parent) + ":" + env.get("PATH", "") + env["CPATH"] = str(profile_root / "usr/include") + ":" + env.get("CPATH", "") + env["LIBRARY_PATH"] = str(profile_root / "usr/lib") + ":" + env.get("LIBRARY_PATH", "") + base_tools = subprocess.run([profile_tools["make"], "-C", str(source / "BaseTools"), + f"CC={profile_tools['clang']}", f"CXX={profile_tools['clang++']}"], env=env, check=False) + if base_tools.returncode: + die("pinned OVMF BaseTools build failed") + configure_edk2_conf(source, spec) + build_env = env.copy() + build_env["WORKSPACE"] = str(source) + build_env["EDK_TOOLS_PATH"] = str(source / "BaseTools") + build_env["PATH"] = ":".join((str(Path(profile_tools["clang"]).parent), str(iasl_path.parent), + str(source / "BaseTools" / "BinWrappers" / "PosixLike"), build_env.get("PATH", ""))) + build_args = ["build", "-a", "X64", "-t", spec["toolchain"], "-b", "RELEASE", "-p", spec["build_target"], *spec["build_flags"]] + result = subprocess.run(build_args, cwd=source, env=build_env, check=False) + if result.returncode: + die("pinned OVMF build failed") + code = source / spec["code_relative"] + vars_template = source / spec["vars_relative"] + if not code.is_file() or not vars_template.is_file(): + die("pinned OVMF build did not produce OVMF_CODE.fd and OVMF_VARS.fd") + firmware_dir = generated / "firmware" + firmware_dir.mkdir() + code_output = firmware_dir / "OVMF_CODE.fd" + nvram_output = firmware_dir / "OVMF_VARS-fresh.fd" + shutil.copy2(code, code_output) + shutil.copy2(vars_template, nvram_output) + epoch = inputs["toolchain"]["source_date_epoch"] + os.utime(code_output, (epoch, epoch)) + os.utime(nvram_output, (epoch, epoch)) + ovmf_profile_records = [] + for package in spec["tool_profile_packages"]: + name = package["name"] + candidates = sorted((dev_root / "mere/cache/packages").glob(f"{name}-{package['version']}-*.pkg.tar.zst")) + if len(candidates) != 1: + die(f"OVMF tool profile package archive is unavailable or ambiguous: {name} {package['version']}") + archive_record = candidates[0] + actual_archive_blake3 = file_blake3(archive_record, mere_binary, dev_root) + if actual_archive_blake3 != package["archive_blake3"]: + die(f"OVMF tool profile archive hash mismatch for {name}: got {actual_archive_blake3}, want {package['archive_blake3']}") + ovmf_profile_records.append({"name": name, "version": package["version"], + "archive_blake3": actual_archive_blake3}) + return { + "source": spec["source_url"], "build": " ".join(build_args), + "source_release": spec["release"], "source_commit": spec["commit"], + "source_sha256": spec["source_sha256"], "source_archive": str(archive), + "source_git_url": spec["git_url"], "source_checkout": "allowlisted-git", + "required_submodules": sorted(spec["required_submodules"]), + "submodule_commits": submodule_commits, + "acpica_source": spec["acpica_source_url"], "acpica_release": spec["acpica_release"], + "acpica_commit": spec["acpica_commit"], "acpica_source_sha256": spec["acpica_source_sha256"], + "acpica_source_archive": str(acpica_archive), + "code_blake3": file_blake3(code_output, mere_binary, dev_root), + "code_path": str(code_output), "nvram_template": str(nvram_output), + "nvram_template_blake3": file_blake3(nvram_output, mere_binary, dev_root), + "ovmf_tool_profile_root": str(profile_root), + "ovmf_tool_profile_packages": sorted(ovmf_profile_records, key=lambda package: package["name"]), + } + + +def tool_profile_archive(dev_root: Path, name: str) -> Path: + archives = sorted((dev_root / "mere/cache/packages").glob(f"{name}-*.pkg.tar.zst")) + if archives: + return archives[-1] + output_archives = sorted( + path for path in Path("/mere/dev/outputs").glob(f"{name}-*/{name}-*.pkg.tar.zst") + if "-dev-" not in path.name + ) + if output_archives: + return output_archives[-1] + die(f"tool profile package archive is unavailable: {name}") + + +def prepare_generated_inputs(args: argparse.Namespace, inputs: dict[str, Any]) -> None: + if not args.mere_dev_behavior: + die("generated staging requires --mere-dev-behavior") + dev_root = args.mere_dev_root or (ROOT / ".swamp" / "mere-dev" / "root") + mere_binary = args.mere_binary or (ROOT / ".swamp" / "mere-dev" / "bin" / f"mere-{inputs['tool_profile']['mere_version']}-linux-x86_64") + if not mere_binary.is_file(): + die(f"pinned Mere {inputs['tool_profile']['mere_version']} binary is unavailable: {mere_binary}") + for package in inputs["packages"]: + if package["name"] != "linux": + # The isolated worker consumes prebuilt package outputs. Package + # compilation belongs in a separately approved Mere build, never + # in rescue-image preparation. + package_build_output(dev_root, package["name"]) + generated = args.output_dir / "generated-inputs" + shutil.rmtree(generated, ignore_errors=True) + generated.mkdir(parents=True) + source_root = generated / "rescue-source" + source_root.mkdir() + package_records: list[dict[str, str]] = [] + package_roots: dict[str, Path] = {} + providers: dict[str, str] = {} + for package in inputs["packages"]: + name = package["name"] + if name == "linux": + continue + package_root, archive, version = package_build_output(dev_root, name) + package_roots[name] = package_root + package_provider_mapping(package_root, name, providers) + merge_tree(package_root, source_root) + package_records.append({"name": name, "version": version, "archive_blake3": file_blake3(archive, mere_binary, dev_root)}) + kernel_dir, kernel_archive, kernel_version = find_existing_kernel( + inputs, + generated, + dev_root, + args.linux_package, + ) + merge_tree(kernel_dir, source_root) + package_provider_mapping(kernel_dir, "linux", providers) + package_records.append({"name": "linux", "version": kernel_version, "archive_blake3": file_blake3(kernel_archive, mere_binary, dev_root)}) + source_root.joinpath("etc").mkdir(parents=True, exist_ok=True) + source_root.joinpath("etc/hostname").write_text("mere-rescue\n", encoding="ascii") + source_root.joinpath("etc/fstab").write_text("# generated rescue fixture\n", encoding="ascii") + staged_root = generated / "rescue-root" + stage_rescue_root(source_root, staged_root, inputs["toolchain"]["source_date_epoch"]) + limine = package_roots["limine"] / "usr/share/limine/BOOTX64.EFI" + if not limine.is_file(): + die("Limine package did not provide usr/share/limine/BOOTX64.EFI") + rescue_limine = generated / "limine.efi" + shutil.copy2(limine, rescue_limine) + fixture_root = None + fixture_esp = None + if args.include_fixture: + fixture_root = generated / "fixture-root" + shutil.copytree(staged_root, fixture_root, symlinks=True) + fixture_root.joinpath("etc/hostname").write_text("mere-system\n", encoding="ascii") + fixture_root.joinpath("etc/fstab").write_text("# generated installed-system fixture\n", encoding="ascii") + fixture_esp = generated / "fixture-esp" + target = fixture_esp / "EFI" / "BOOT" + target.mkdir(parents=True) + shutil.copy2(limine, target / "BOOTX64.EFI") + shutil.copy2(staged_root / "boot/vmlinux", target / "vmlinuz") + write_limine_config(target / "limine.conf", inputs["fixture"]["root_guid"]) + repository_files = list((dev_root / "mere/cache/repos").glob("*/repo.db")) + if not repository_files: + die("Mere repository snapshot is unavailable after package preparation") + repository_snapshot = file_blake3(repository_files[-1], mere_binary, dev_root) + tool_records: list[dict[str, str]] = [] + for name in inputs["tool_profile"]["packages"]: + archive = tool_profile_archive(dev_root, name) + match = re.match(rf"{re.escape(name)}-(.+)-x86_64-[0-9a-f]+\.pkg\.tar\.zst$", archive.name) + if not match: + die(f"cannot parse tool profile archive version: {archive.name}") + tool_records.append({"name": name, "version": match.group(1), + "archive_blake3": file_blake3(archive, mere_binary, dev_root)}) + package_manifest = { + "mere": {"version": inputs["tool_profile"]["mere_version"], "binary_blake3": file_blake3(mere_binary, mere_binary, dev_root), + "binary_sha256": inputs["tool_profile"]["mere_binary_sha256"], "repository_snapshot": repository_snapshot}, + "packages": sorted(package_records, key=lambda package: package["name"]), + "providers": dict(sorted(providers.items())), + "tool_profile_packages": sorted(tool_records, key=lambda package: package["name"]), + } + package_manifest_path = generated / "package-manifest.json" + package_manifest_path.write_text(json.dumps(package_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + firmware_manifest = prepare_ovmf(args, inputs, generated, mere_binary, dev_root) + firmware_manifest_path = generated / "firmware-manifest.json" + firmware_manifest_path.write_text(json.dumps(firmware_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.root_tree = staged_root + if args.include_fixture: + args.fixture_root_tree, args.fixture_esp_tree = fixture_root, fixture_esp + args.kernel, args.limine_efi = staged_root / "boot/vmlinux", rescue_limine + args.package_manifest, args.firmware_manifest = package_manifest_path, firmware_manifest_path + + +def parse() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + parser.add_argument("--build", action="store_true") + parser.add_argument("--build-packages", action="store_true") + parser.add_argument("--prepare-generated-inputs", action="store_true") + parser.add_argument("--mere-dev-behavior") + parser.add_argument("--mere-dev-root", type=Path, + default=None) + parser.add_argument("--mere-binary", type=Path) + parser.add_argument("--mere-version") + parser.add_argument("--linux-package", type=Path) + parser.add_argument("--ovmf-code", type=Path) + parser.add_argument("--ovmf-source-archive", type=Path) + parser.add_argument("--acpica-source-archive", type=Path) + parser.add_argument("--ovmf-tool-profile-root", type=Path) + parser.add_argument("--ovmf-source") + parser.add_argument("--ovmf-build") + parser.add_argument("--nvram-template", type=Path) + parser.add_argument("--tool-profile-smoke", action="store_true") + parser.add_argument("--tool-shell-behavior") + parser.add_argument("--tool-profile-root", type=Path, + help="validated Mere profile root for image tools") + parser.add_argument("--swamp-repo-dir", type=Path) + parser.add_argument("--swamp-server-token-file", type=Path) + parser.add_argument("--root-tree", type=Path) + parser.add_argument("--fixture-root-tree", type=Path) + parser.add_argument("--fixture-esp-tree", type=Path) + parser.add_argument("--kernel", type=Path) + parser.add_argument("--limine-efi", type=Path) + parser.add_argument("--package-manifest", type=Path) + parser.add_argument("--firmware-manifest", type=Path) + parser.add_argument("--output-dir", type=Path, default=Path("out/rescue-image")) + parser.add_argument("--compare", action="store_true") + parser.add_argument("--include-fixture", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse() + inputs = json.loads(INPUTS.read_text(encoding="utf-8")) + check_inputs(inputs) + if args.mere_version and args.mere_version != inputs["tool_profile"]["mere_version"]: + die("--mere-version does not match the pinned image input") + os.environ.update({key: str(inputs["toolchain"][key]) for key in ("timezone", "locale")}) + os.environ["TZ"] = inputs["toolchain"]["timezone"] + os.environ["LC_ALL"] = inputs["toolchain"]["locale"] + os.environ["SOURCE_DATE_EPOCH"] = str(inputs["toolchain"]["source_date_epoch"]) + os.umask(int(inputs["toolchain"]["umask"], 8)) + for package in inputs["packages"]: + if not (ROOT / package["recipe"]).is_file(): + die(f"pinned recipe is missing: {package['recipe']}") + if args.check: + print(json.dumps({"inputs": str(INPUTS), "packages": [p["name"] for p in inputs["packages"]]}, sort_keys=True)) + return 0 + if args.tool_profile_smoke and not args.build: + if shutil.which("swamp") is None: + die("tool profile smoke requires swamp") + observations = invoke_tool_profile(args, inputs) + print(json.dumps({"tool_profile": inputs["tool_profile"], "observations": observations, "smoke": "validated"}, sort_keys=True)) + return 0 + if args.prepare_generated_inputs: + if not args.build_packages: + die("--prepare-generated-inputs requires --build-packages") + if args.tool_profile_root is None: + die("--prepare-generated-inputs requires --tool-profile-root") + if args.linux_package is None: + die("--prepare-generated-inputs requires --linux-package") + check_environment( + profile_root=args.tool_profile_root, + inputs=inputs, + include_swamp=True, + ) + prepare_generated_inputs(args, inputs) + print(json.dumps({ + "generated": str(args.output_dir / "generated-inputs"), + "package_manifest": str(args.package_manifest), + "firmware_manifest": str(args.firmware_manifest), + }, sort_keys=True)) + return 0 + if not args.build: + die("--build is required") + if args.compare: + args.include_fixture = True + if args.tool_profile_root is None: + die("--build requires --tool-profile-root for the validated Mere image-tool profile") + check_environment(profile_root=args.tool_profile_root, inputs=inputs, + include_swamp=args.build_packages or args.tool_profile_smoke) + if args.tool_profile_smoke: + invoke_tool_profile(args, inputs) + image_tool_versions = tool_versions() + required_paths = [ + (args.root_tree, "--root-tree"), + (args.kernel, "--kernel"), + (args.limine_efi, "--limine-efi"), + (args.package_manifest, "--package-manifest"), + (args.firmware_manifest, "--firmware-manifest"), + ] + if args.include_fixture: + required_paths.extend([ + (args.fixture_root_tree, "--fixture-root-tree"), + (args.fixture_esp_tree, "--fixture-esp-tree"), + ]) + for path, label in required_paths: + if path is None or not path.exists(): + die(f"{label} must name an existing input") + if args.include_fixture and (not args.fixture_esp_tree.is_dir() or not any(args.fixture_esp_tree.rglob("*"))): + die("--fixture-esp-tree must be a non-empty directory") + package_manifest = json.loads(args.package_manifest.read_text(encoding="utf-8")) + firmware_manifest = json.loads(args.firmware_manifest.read_text(encoding="utf-8")) + if not package_manifest.get("packages") or not package_manifest.get("mere"): + die("package manifest needs mere and packages") + if not package_manifest.get("providers"): + die("package manifest needs generated package-provider mapping") + if package_manifest["providers"].get("/boot/vmlinux") != "linux" or package_manifest["providers"].get("/boot/config") != "linux": + die("package manifest provider mapping must identify the Linux boot artifacts") + if not all(package_manifest["mere"].get(key) for key in ("version", "binary_blake3", "binary_sha256", "repository_snapshot")): + die("package manifest needs Mere version, binary_blake3, binary_sha256, and repository_snapshot") + if package_manifest["mere"].get("version") != inputs["tool_profile"]["mere_version"] or package_manifest["mere"].get("binary_sha256") != inputs["tool_profile"]["mere_binary_sha256"]: + die("package manifest Mere version or binary SHA-256 does not match pinned tool profile") + if not all(firmware_manifest.get(key) for key in ("source", "build", "code_blake3", "nvram_template")): + die("firmware manifest needs source, build, code_blake3, and nvram_template") + expected = {p["name"] for p in inputs["packages"]} + actual = {p.get("name") for p in package_manifest["packages"]} + if expected != actual: + die("package manifest names do not match pinned image package set") + for package in package_manifest["packages"]: + digest_value = package.get("archive_blake3", "") + if not package.get("version") or len(digest_value) != 64 or any(c not in "0123456789abcdef" for c in digest_value.lower()): + die("each image package needs version and a 64-character archive_blake3") + profile_packages = package_manifest.get("tool_profile_packages", []) + if {package.get("name") for package in profile_packages} != set(inputs["tool_profile"]["packages"]): + die("package manifest tool_profile_packages do not match pinned tool profile") + for package in profile_packages: + digest_value = package.get("archive_blake3", "") + if not package.get("version") or len(digest_value) != 64 or any(c not in "0123456789abcdef" for c in digest_value.lower()): + die("each tool profile package needs version and a 64-character archive_blake3") + check_tree(args.root_tree, image=True) + if args.include_fixture: + check_tree(args.fixture_root_tree, image=False) + stage_dir = Path(tempfile.mkdtemp(prefix="mere-rescue-root-")) + try: + staged_root = stage_dir / "root" + stage_rescue_root(args.root_tree, staged_root, inputs["toolchain"]["source_date_epoch"]) + args.output_dir.mkdir(parents=True, exist_ok=True) + first = build_one(args.output_dir / "mere-rescue.raw", staged_root, args.kernel, args.limine_efi, None, inputs, fixture=False) + fixture = None + if args.include_fixture: + fixture = build_one(args.output_dir / "mere-system-fixture.raw", args.fixture_root_tree, args.kernel, args.limine_efi, args.fixture_esp_tree, inputs, fixture=True) + + second = None + second_fixture = None + dirty = None + dirty_hash = None + rescue_equal = False + fixture_equal = False + if args.compare: + second = build_one(args.output_dir / "compare" / "mere-rescue.raw", staged_root, args.kernel, args.limine_efi, None, inputs, fixture=False) + rescue_equal = first["blake3"] == second["blake3"] + if fixture is None: + die("--compare requires --include-fixture") + second_fixture = build_one(args.output_dir / "compare" / "mere-system-fixture.raw", args.fixture_root_tree, args.kernel, args.limine_efi, args.fixture_esp_tree, inputs, fixture=True) + fixture_equal = fixture["blake3"] == second_fixture["blake3"] + dirty = args.output_dir / "mere-system-fixture-dirty-journal.raw" + shutil.copyfile(args.output_dir / "mere-system-fixture.raw", dirty) + dirty_start = inputs["image"]["esp_start_lba"] * inputs["image"]["sector_size"] + inputs["image"]["esp_size_bytes"] + with tempfile.TemporaryDirectory(prefix="mere-fixture-dirty-") as mounted: + command([executable("mount"), "-o", f"loop,offset={dirty_start},sizelimit={inputs['image']['size_bytes'] - dirty_start}", str(dirty), mounted]) + try: + marker = Path(mounted) / "var" / "lib" / "mere-rescue-dirty-journal-marker" + marker.parent.mkdir(parents=True, exist_ok=True) + with marker.open("w", encoding="ascii") as stream: + stream.write("known dirty-journal fixture\n") + stream.flush() + os.fsync(stream.fileno()) + dirty_hash = digest(dirty) + finally: + command([executable("umount"), "-l", mounted]) + + artifacts = {"rescue": {"first": first}} + if second is not None: + artifacts["rescue"].update({"second": second, "byte_identical": rescue_equal}) + if fixture is not None: + artifacts["fixture"] = {"first": fixture} + if second_fixture is not None: + artifacts["fixture"].update({"second": second_fixture, "byte_identical": fixture_equal}) + if dirty is not None and dirty_hash is not None: + artifacts["fixture"]["dirty_journal"] = {"path": str(dirty), "blake3": dirty_hash} + manifest = { + "format": 1, "architecture": "x86_64", "boot": "UEFI-SecureBoot-disabled", + "source_revision": command(["git", "rev-parse", "HEAD"], cwd=ROOT, capture=True).strip(), + "image_builder_revision": "working-tree", "environment": { + "SOURCE_DATE_EPOCH": inputs["toolchain"]["source_date_epoch"], "TZ": inputs["toolchain"]["timezone"], + "LC_ALL": inputs["toolchain"]["locale"], "umask": inputs["toolchain"]["umask"]}, + "inputs": inputs, "tool_profile": inputs["tool_profile"], + "tool_profile_root": str(args.tool_profile_root), + "resolved_image_tools": {tool: executable(tool) for tool in PROFILE_IMAGE_TOOLS}, + "package_manifest": package_manifest, "tool_profile_packages": package_manifest["tool_profile_packages"], "firmware_manifest": firmware_manifest, + "image_tool_versions": image_tool_versions, "commands": COMMAND_LOG, + "artifacts": artifacts, + "reproducibility": {"clean_builds_compared": args.compare, "rescue_byte_identical": rescue_equal, + "fixture_byte_identical": fixture_equal}, "no_initramfs": True, + } + (args.output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if args.compare and (not rescue_equal or not fixture_equal): + die("clean builds differ; manifest records failed reproducibility comparison") + finally: + shutil.rmtree(stage_dir, ignore_errors=True) + print(json.dumps(manifest["reproducibility"], sort_keys=True)) + return 0 + + +if __name__ == "__main__": + main() diff --git a/misc-util/rescue-image.inputs.json b/misc-util/rescue-image.inputs.json new file mode 100644 index 00000000..81e7165e --- /dev/null +++ b/misc-util/rescue-image.inputs.json @@ -0,0 +1,93 @@ +{ + "format": 1, + "image": { + "size_bytes": 2147483648, + "sector_size": 512, + "esp_size_bytes": 268435456, + "esp_start_lba": 2048, + "disk_guid": "8e9f8a4c-6480-4f1e-9a19-4b79b6b8a648", + "esp_guid": "8e9f8a4c-6480-4f1e-9a19-4b79b6b8a649", + "root_guid": "8e9f8a4c-6480-4f1e-9a19-4b79b6b8a64a", + "esp_type_guid": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "root_type_guid": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", + "fat_volume_id": "4d455245", + "fat_label": "MERE-ESP", + "ext4_uuid": "64806480-4ef1-4e5c-9e00-648064806480", + "ext4_hash_seed": "64806480-6480-4480-8480-648064806480", + "ext4_journal_uuid": "64806480-4ef1-4e5c-9e00-648064806481", + "ext4_features": ["has_journal", "extent", "64bit", "metadata_csum", "dir_index"] + }, + "fixture": { + "disk_guid": "8e9f8a4c-6500-4f1e-9a19-4b79b6b8a650", + "esp_guid": "8e9f8a4c-6500-4f1e-9a19-4b79b6b8a651", + "root_guid": "8e9f8a4c-6500-4f1e-9a19-4b79b6b8a652", + "ext4_uuid": "65006500-4ef1-4e5c-9e00-650065006500", + "ext4_journal_uuid": "65006500-4ef1-4e5c-9e00-650065006501", + "fat_volume_id": "4d455246" + }, + "toolchain": { + "mere_version": "0.23.0", + "mere_binary_sha256": "3250c04b7f288056f5db8860a644a17895a7dc6398ddc961e1316bfe928ea631", + "limine_version": "12.5.2", + "linux_version": "6.18.62", + "source_date_epoch": 1767225600, + "timezone": "UTC", + "locale": "C", + "umask": "0022" + }, + "tool_profile": { + "mere_version": "0.23.0", + "mere_binary_sha256": "3250c04b7f288056f5db8860a644a17895a7dc6398ddc961e1316bfe928ea631", + "packages": ["busybox", "parted", "e2fsprogs"], + "commands": [ + {"name": "parted", "argv": ["/sbin/parted", "--version"]}, + {"name": "mkfs.vfat", "argv": ["/bin/busybox", "mkfs.vfat", "--help"]}, + {"name": "mkfs.ext4", "argv": ["/sbin/mkfs.ext4", "-V"]}, + {"name": "losetup", "argv": ["/bin/busybox", "losetup", "--help"]}, + {"name": "tune2fs", "argv": ["/sbin/tune2fs", "-V"], "expected_exit_code": 1}, + {"name": "debugfs", "argv": ["/sbin/debugfs", "-V"]} + ] + }, + "ovmf": { + "release": "edk2-stable202608", + "commit": "2970e5699ba6267f3384ffab20f96647578aebc8", + "source_url": "https://codeload.github.com/tianocore/edk2/tar.gz/refs/tags/edk2-stable202608", + "git_url": "https://github.com/tianocore/edk2.git", + "source_sha256": "bac40812213576c3025cf780c33d882f9e087a44201b1cd28a07f6d1eb54b76a", + "build_target": "OvmfPkg/OvmfPkgX64.dsc", + "toolchain": "CLANGDWARF", + "code_relative": "Build/OvmfX64/RELEASE_CLANGDWARF/FV/OVMF_CODE.fd", + "vars_relative": "Build/OvmfX64/RELEASE_CLANGDWARF/FV/OVMF_VARS.fd", + "acpica_release": "20260930", + "acpica_commit": "e85aa3bebaae81ed1e6d163511ac7e1eafe732cf", + "acpica_source_url": "https://codeload.github.com/open-acpica/acpica/tar.gz/refs/tags/20260930", + "acpica_source_sha256": "feddab0f42f1e01afa3b8ec04f3aaef5e61e891225b8dc8776953ca9f3df1449", + "required_submodules": [ + "BaseTools/Source/C/BrotliCompress/brotli", + "CryptoPkg/Library/MbedTlsLib/mbedtls", + "CryptoPkg/Library/OpensslLib/openssl", + "MdeModulePkg/Library/BrotliCustomDecompressLib/brotli", + "MdePkg/Library/MipiSysTLib/mipisyst", + "SecurityPkg/DeviceSecurity/SpdmLib/libspdm" + ], + "tool_profile_packages": [ + {"name": "llvm", "version": "21.1.8-14", "archive_blake3": "98ba485cc5297ce83b4b3afc444a29fb2f33687a12ac8abf13fe973372da88ec"}, + {"name": "nasm", "version": "3.01-1", "archive_blake3": "6bd28b1f8cb239b1d6329bce80f857ee7db93f0de916625690a055b46b5ebcaf"}, + {"name": "flex", "version": "2.6.4-1", "archive_blake3": "087478d5a21b1a9548204b3aebdd273730cd7c5ce84e8c1c7d728aac6f8f6ee7"}, + {"name": "bison", "version": "3.8.2-1", "archive_blake3": "b3764ac1ab30566d3b80aff7736b32a799e910431e871b69bba20e0b9dcea73b"}, + {"name": "make", "version": "4.4.1-3", "archive_blake3": "e9822208de054469f10bdd40ba1ff0dbc2fdd04f1b9763005d79b3dda2b091df"}, + {"name": "m4", "version": "1.4.21-1", "archive_blake3": "6954a828ea88d2b5c09cd80dfe368e769abba9cfb5fcbc61ffdeafc0fa018ce2"}, + {"name": "util-linux-libs-dev", "version": "2.42.2-1", "archive_blake3": "c7cf8eefc414caac10d0961131669b095ac0a1f5848ba54b01989b2f745caae0"} + ], + "build_flags": ["-D", "NETWORK_ENABLE=FALSE", "-D", "SECURE_BOOT_ENABLE=FALSE", "-D", "SMM_REQUIRE=FALSE"] + }, + "packages": [ + {"name": "busybox", "recipe": "recipes/core/busybox/recipe.kdl", "commands": ["sh", "cp", "mount", "umount"]}, + {"name": "dinit", "recipe": "recipes/core/dinit/recipe.kdl", "commands": ["dinit", "dinitctl"]}, + {"name": "e2fsprogs", "recipe": "recipes/core/e2fsprogs/recipe.kdl", "commands": ["e2fsck", "tune2fs", "debugfs"]}, + {"name": "linux", "recipe": "recipes/core/linux/recipe.kdl", "commands": []}, + {"name": "limine", "recipe": "recipes/core/limine/recipe.kdl", "commands": []}, + {"name": "mdevd", "recipe": "recipes/core/mdevd/recipe.kdl", "commands": ["mdevd"]}, + {"name": "parted", "recipe": "recipes/core/parted/recipe.kdl", "commands": ["parted"]} + ] +} diff --git a/misc-util/rescue-services/mdevd b/misc-util/rescue-services/mdevd new file mode 100644 index 00000000..68101ff7 --- /dev/null +++ b/misc-util/rescue-services/mdevd @@ -0,0 +1,4 @@ +type = process +command = /sbin/mdevd -D 3 +ready-notification = 3 +depends-on = mount-dev mount-proc mount-sys diff --git a/misc-util/rescue-services/mdevd-coldplug b/misc-util/rescue-services/mdevd-coldplug new file mode 100644 index 00000000..470f5193 --- /dev/null +++ b/misc-util/rescue-services/mdevd-coldplug @@ -0,0 +1,3 @@ +type = process +command = /sbin/mdevd-coldplug +depends-on = mdevd diff --git a/misc-util/rescue-services/mount-dev b/misc-util/rescue-services/mount-dev new file mode 100644 index 00000000..608f5520 --- /dev/null +++ b/misc-util/rescue-services/mount-dev @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t devtmpfs devtmpfs /dev diff --git a/misc-util/rescue-services/mount-devpts b/misc-util/rescue-services/mount-devpts new file mode 100644 index 00000000..b9f10463 --- /dev/null +++ b/misc-util/rescue-services/mount-devpts @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t devpts devpts /dev/pts diff --git a/misc-util/rescue-services/mount-proc b/misc-util/rescue-services/mount-proc new file mode 100644 index 00000000..8137537d --- /dev/null +++ b/misc-util/rescue-services/mount-proc @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t proc proc /proc diff --git a/misc-util/rescue-services/mount-runtime b/misc-util/rescue-services/mount-runtime new file mode 100644 index 00000000..8b3ae6ae --- /dev/null +++ b/misc-util/rescue-services/mount-runtime @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t tmpfs tmpfs /run diff --git a/misc-util/rescue-services/mount-shm b/misc-util/rescue-services/mount-shm new file mode 100644 index 00000000..527e5789 --- /dev/null +++ b/misc-util/rescue-services/mount-shm @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t tmpfs tmpfs /dev/shm diff --git a/misc-util/rescue-services/mount-sys b/misc-util/rescue-services/mount-sys new file mode 100644 index 00000000..87e91d31 --- /dev/null +++ b/misc-util/rescue-services/mount-sys @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t sysfs sysfs /sys diff --git a/misc-util/rescue-services/mount-tmp b/misc-util/rescue-services/mount-tmp new file mode 100644 index 00000000..66b48358 --- /dev/null +++ b/misc-util/rescue-services/mount-tmp @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t tmpfs tmpfs /tmp diff --git a/misc-util/rescue-services/mount-var-log b/misc-util/rescue-services/mount-var-log new file mode 100644 index 00000000..2ffe635f --- /dev/null +++ b/misc-util/rescue-services/mount-var-log @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t tmpfs tmpfs /var/log diff --git a/misc-util/rescue-services/mount-var-tmp b/misc-util/rescue-services/mount-var-tmp new file mode 100644 index 00000000..531125cb --- /dev/null +++ b/misc-util/rescue-services/mount-var-tmp @@ -0,0 +1,2 @@ +type = process +command = /bin/mount -t tmpfs tmpfs /var/tmp diff --git a/misc-util/rescue-services/rescue-shell b/misc-util/rescue-services/rescue-shell new file mode 100644 index 00000000..4a8d3bb0 --- /dev/null +++ b/misc-util/rescue-services/rescue-shell @@ -0,0 +1,4 @@ +type = process +command = /bin/sh -c 'printf "MERE-LINUX-RESCUE-READY pid=%s\\n" "$$" > /dev/console; exec /bin/sh' +depends-on = mdevd-coldplug mount-devpts mount-shm mount-runtime mount-tmp mount-var-log mount-var-tmp +runs-on-console = true diff --git a/recipes/core/linux/mere-x86_64.config.fragment b/recipes/core/linux/mere-x86_64.config.fragment index 40151ea3..a1279686 100644 --- a/recipes/core/linux/mere-x86_64.config.fragment +++ b/recipes/core/linux/mere-x86_64.config.fragment @@ -24,6 +24,14 @@ CONFIG_PM_AUTOSLEEP=y # In 6.19, MICROCODE_INTEL/MICROCODE_AMD merged into MICROCODE. CONFIG_MICROCODE=y +# --- KVM virtualization --- +# Keep the hypervisor core built in and ship both vendor backends as modules. +# This lets the same x86_64 kernel host KVM guests on Intel and AMD systems. +CONFIG_VIRTUALIZATION=y +CONFIG_KVM=y +CONFIG_KVM_INTEL=m +CONFIG_KVM_AMD=m + # --- Graphics --- CONFIG_DRM_I915=m CONFIG_DRM_AMDGPU=m diff --git a/recipes/core/linux/recipe.kdl b/recipes/core/linux/recipe.kdl index ce87c83c..b4989c00 100644 --- a/recipes/core/linux/recipe.kdl +++ b/recipes/core/linux/recipe.kdl @@ -1,7 +1,7 @@ recipe { name "linux" - version "6.18.42" - release 2 + version "6.18.62" + release 1 description "Linux kernel and modules" url "http://www.kernel.org" licenses "GPL2" @@ -24,7 +24,7 @@ recipe { } source "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${recipe.version}.tar.xz" { - blake3 "a39abc6d59532b3f8a779f1130eb56077ae8ea3fc887c3585218e5d90c1c3cce" + blake3 "1292d1098eb0bdd33e7636260489533fa02ac8c8c3940a640a05d46eaddbced5" } source "mere-common.config.fragment" {