From bba23552ab187d52f930bf4d2c2628ce4e9621a9 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Mon, 2 Nov 2026 07:54:07 -0500 Subject: [PATCH] Exercise the published boot contract in QEMU --- test/qemu/README.md | 68 ++++++++ test/qemu/boot-fixtures/check | 18 +++ test/qemu/boot-fixtures/group | 10 ++ test/qemu/boot-fixtures/lifetime | 8 + test/qemu/packaged-boot-smoke | 5 + test/qemu/packaged-boot.py | 260 +++++++++++++++++++++++++++++++ test/qemu/prepare-root | 23 ++- 7 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 test/qemu/boot-fixtures/check create mode 100644 test/qemu/boot-fixtures/group create mode 100644 test/qemu/boot-fixtures/lifetime create mode 100755 test/qemu/packaged-boot-smoke create mode 100644 test/qemu/packaged-boot.py diff --git a/test/qemu/README.md b/test/qemu/README.md index e9dff4f8..6442b30c 100644 --- a/test/qemu/README.md +++ b/test/qemu/README.md @@ -176,3 +176,71 @@ directory. Replay from a plain dinit run lacks the required zstd package and is rejected before boot. As with `dinit-smoke`, host replay still needs network access and repository retention; guest execution is offline. The fixture tests Mere shell/build behavior, not production service integration or bootloader setup. + +## Published boot contract + +```sh +QEMU_TIMEOUT=180 sh test/qemu/packaged-boot-smoke /absolute/path/to/mere \ + /absolute/new/boot-results +``` + +This x86_64 launcher installs the published `linux`, `busybox`, `dinit`, `shadow`, +`mdevd`, and `mere-boot` packages into a fresh **system** profile with the dinit +provider selected. Mere activates their `/etc` defaults and generates their +service definitions. The launcher keeps the packaged boot graph and helpers; +it adds only machine configuration, an optional serial login, and a test service +that records whether `/run` and an fstab filesystem are still mounted when it +stops. No recipes are built and no production services are replaced. + +Host requirements are Python 3.9 or later, a host-runnable Mere binary, an +x86_64 host, QEMU with TCG/PC firmware, `mke2fs`, `e2fsck`, `debugfs`, `openssl`, +and the `prepare-root` utilities listed above. No root privileges, host mounts, +loop devices, KVM, or automatic host software installation are required. The +packaged kernel needs the built-in disk/filesystem facilities described above. +`QEMU` accepts one executable or wrapper path. `QEMU_TIMEOUT` is the deadline in +seconds for each scenario (including both reboot cycles); `QEMU_DISK_MIB` +defaults to 512 per disposable image. + +Three independent ext4 images exercise: + +1. Boot from a read-only root through `/sbin/init`, authenticate through the + packaged serial getty/login, check foundational mounts, runtime permissions, + hostname, coldplug completion directory and the dinit control socket, then + power off. +2. Repeat those checks, write persistent data, reboot, authenticate again, verify + the data and first shutdown's service-stop record, and power off. +3. Fail a required fstab mount, wait for dinit's boot-failure menu, select `e`, + reject an incorrect password, accept the fixture root password, and verify + root identity and a working `dinitctl` before explicitly powering off. + +QEMU has no network device and directly loads the packaged kernel, without an +initramfs or bootloader. Each image must pass `e2fsck -fn` before and after boot; +the checker never repairs a filesystem. Successful normal shutdowns must leave +one stop record per boot proving the runtime and fstab mounts survived service +shutdown. Recovery must show exactly one menu and no reboot. Failed commands, +missing markers, unexpected menus, timeouts, and inconsistent filesystems fail +the launcher. + +The root account and its public password `mere-guest-test` are **disposable test +fixtures**, not package defaults or installation guidance. Never boot these +images with networking or use this account configuration on an actual system. +The temporary root and disks are deleted, including on failure. The new results +directory retains setup/serial/assembly logs, before/after filesystem reports, +and stop records. `inputs/` records the Mere binary digest/version, package +identities, upstream configuration/key and hashes, effective provider config, +kernel digest, QEMU version, and test driver/fixtures. + +Replay package identities and trust inputs with: + +```sh +QEMU=/path/to/qemu-wrapper sh test/qemu/packaged-boot-smoke /absolute/path/to/mere \ + /absolute/new/replay-results /absolute/previous/boot-results/inputs +``` + +Use inputs from this launcher. Replay requires the same Mere digest and host +architecture, network access during preparation, and retention of the recorded +packages by the repository. It is not an offline archive. QEMU version and host +tools are recorded or supplied separately; replay does not pin them. Guest +execution is offline. This covers the minimum packaged boot contract under #634; +bootloader setup, package upgrade/removal/rollback, and broader service lifecycle +behavior remain separate work. diff --git a/test/qemu/boot-fixtures/check b/test/qemu/boot-fixtures/check new file mode 100644 index 00000000..2bfe7468 --- /dev/null +++ b/test/qemu/boot-fixtures/check @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu +export PATH=/bin:/sbin:/usr/bin:/usr/sbin +[ "$(id -u)" = 0 ] +[ "$(cat /proc/1/comm)" = dinit ] +[ "$(hostname)" = mere-boot-test ] +for dir in /proc /sys /dev /run /dev/pts /dev/shm /mnt/test; do + mountpoint -q "$dir" +done +[ -d /run/.libudev-zero ] +[ -S /run/dinitctl ] +[ "$(stat -c %a /tmp)" = 1777 ] +[ "$(stat -c %a /var/tmp)" = 1777 ] +for service in mdevd mdevd-coldplug mount-fstab sysinit tty1 ttyS0 boot boot-test-lifetime; do + dinitctl is-started "$service" +done +dinitctl list +printf 'PACKAGED_%s_PASS\n' BOOT diff --git a/test/qemu/boot-fixtures/group b/test/qemu/boot-fixtures/group new file mode 100644 index 00000000..28914297 --- /dev/null +++ b/test/qemu/boot-fixtures/group @@ -0,0 +1,10 @@ +root:x:0: +tty:x:5: +disk:x:6: +uucp:x:14: +cdrom:x:19: +audio:x:29: +video:x:44: +floppy:x:11: +kvm:x:61: +input:x:101: diff --git a/test/qemu/boot-fixtures/lifetime b/test/qemu/boot-fixtures/lifetime new file mode 100644 index 00000000..662c77d6 --- /dev/null +++ b/test/qemu/boot-fixtures/lifetime @@ -0,0 +1,8 @@ +#!/bin/sh +# dinit calls this only when stopping the successfully started test service. +set -eu +export PATH=/bin:/sbin:/usr/bin:/usr/sbin +mountpoint -q /run +mountpoint -q /mnt/test +printf 'mounts-present\n' >> /var/log/boot-test-stops +sync diff --git a/test/qemu/packaged-boot-smoke b/test/qemu/packaged-boot-smoke new file mode 100755 index 00000000..e65c840b --- /dev/null +++ b/test/qemu/packaged-boot-smoke @@ -0,0 +1,5 @@ +#!/bin/sh +# Python handles the serial dialogue without expect or a host pseudo-terminal. +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$script_dir/packaged-boot.py" "$@" diff --git a/test/qemu/packaged-boot.py b/test/qemu/packaged-boot.py new file mode 100644 index 00000000..b9100aea --- /dev/null +++ b/test/qemu/packaged-boot.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Disposable, offline QEMU checks of the published Mere boot contract.""" +import hashlib +import math +import os +from pathlib import Path +import re +import selectors +import shutil +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +PASSWORD = "mere-guest-test" # Public fixture credential; never use on a real system. +PACKAGES = {"linux", "busybox", "dinit", "shadow", "mdevd", "mere-boot"} + + +def run(args, log=None, **kwargs): + return subprocess.run(args, check=True, stdout=log, stderr=log, **kwargs) + + +def write(root, name, text, mode=0o644): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(mode) + + +def fsck(disk, log): + # -n never repairs: even a repairable inconsistency fails the scenario. + with log.open("wb") as output: + run(["e2fsck", "-fn", str(disk)], output) + + +def dialogue(qemu, kernel, disk, case, output, timeout): + command = [qemu, "-accel", "tcg,thread=single", "-m", "512", "-smp", "1", + "-nodefaults", "-display", "none", "-serial", "stdio", "-monitor", "none", + "-nic", "none", "-kernel", str(kernel), "-drive", + f"file={disk},format=raw,if=ide", "-append", + "console=ttyS0 root=/dev/sda rootfstype=ext4 ro init=/sbin/init panic=-1"] + if case != "reboot": + command += ["-no-reboot"] + with output.open("wb") as log, subprocess.Popen( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) as proc: + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ) + transcript = b"" + pending = b"" + stage = "menu" if case == "recovery" else "login" + cycle = 0 + wrong_sent = False + deadline = time.monotonic() + timeout + + def send(text): + proc.stdin.write(text.encode()) + proc.stdin.flush() + + try: + while True: + if time.monotonic() > deadline: + raise RuntimeError(f"{case}: timed out in {stage}") + for key, _ in selector.select(0.2): + chunk = os.read(key.fd, 65536) + if not chunk: + selector.unregister(key.fileobj) + continue + log.write(chunk) + log.flush() + transcript += chunk + pending += chunk + if case != "recovery" and stage != "shutdown" and b"(p)ower off? " in pending: + send("p") + stage = "shutdown" + # Consume only up to each prompt so coalesced output is preserved. + while True: + if stage == "menu": + prompt = b"(p)ower off? " + elif stage == "password": + prompt = b"Password:" + elif stage in ("login", "retry"): + prompt = b"login:" + elif stage == "shell": + # Root's default shell prompt from the packaged login/profile. + prompt = b"# " + else: + break + pos = pending.find(prompt) + if pos < 0: + break + consumed = pending[:pos + len(prompt)] + pending = pending[pos + len(prompt):] + if stage == "menu": + if b"Password:" in consumed: + raise RuntimeError("Recovery login began before menu selection") + send("e") + stage = "password" + elif stage in ("login", "retry"): + if stage == "retry" and b"Login incorrect" not in consumed: + raise RuntimeError("Wrong recovery password was not rejected") + send("root\n") + stage = "password" + elif stage == "password": + if case == "recovery" and not wrong_sent: + send("incorrect-guest-password\n") + wrong_sent = True + stage = "retry" + else: + send(PASSWORD + "\n") + stage = "shell" + elif stage == "shell": + if case == "recovery": + send("[ \"$(id -u)\" = 0 ] && dinitctl list >/dev/null && " + "printf 'PACKAGED_%s_PASS\\n' RECOVERY && /sbin/shutdown -p\n") + stage = "shutdown" + elif case == "reboot" and cycle == 0: + send("/bin/sh /etc/boot-test/check && " + "printf 'persistent-data\\n' > /var/log/boot-test-data && " + "sync && /sbin/shutdown -r\n") + cycle = 1 + stage = "login" + else: + check = "/bin/sh /etc/boot-test/check && " + if case == "reboot": + check += ("grep -qx persistent-data /var/log/boot-test-data && " + "grep -qx mounts-present /var/log/boot-test-stops && " + "printf 'PACKAGED_%s_PASS\\n' REBOOT && ") + send(check + "/sbin/shutdown -p\n") + stage = "shutdown" + if not selector.get_map() and proc.poll() is not None: + break + if proc.wait() != 0: + raise RuntimeError(f"{case}: QEMU exited {proc.returncode}") + finally: + selector.close() + if proc.poll() is None: + proc.kill() + proc.wait() + text = transcript.decode(errors="replace") + required = ["Issuing shutdown via kernel", "reboot: Power down"] + if case == "recovery": + required += ["PACKAGED_RECOVERY_PASS", "Login incorrect", "does-not-exist"] + if text.count("(p)ower off? ") != 1 or "reboot: Restarting system" in text: + raise RuntimeError("Recovery must use exactly one menu and never reboot") + if "PACKAGED_BOOT_PASS" in text: + raise RuntimeError("Failed boot reported normal boot completion") + else: + required += ["PACKAGED_BOOT_PASS"] + if "(p)ower off? " in text: + raise RuntimeError(f"{case}: unexpected boot failure menu") + if case == "reboot": + required += ["PACKAGED_REBOOT_PASS", "reboot: Restarting system"] + if text.count("PACKAGED_BOOT_PASS") != 2: + raise RuntimeError("Reboot must pass both boot cycles") + for marker in required: + if marker not in text: + raise RuntimeError(f"{case}: missing {marker}") + + +def main(): + if len(sys.argv) not in (3, 4): + raise RuntimeError("Usage: packaged-boot-smoke ABSOLUTE_MERE NEW_ABSOLUTE_RESULTS [PREVIOUS_INPUTS]") + mere, results = Path(sys.argv[1]), Path(sys.argv[2]) + if not mere.is_absolute() or not results.is_absolute(): + raise RuntimeError("Mere and results paths must be absolute") + if os.uname().machine != "x86_64": + raise RuntimeError("This launcher requires an x86_64 host and packages") + qemu = os.environ.get("QEMU", "qemu-system-x86_64") + for tool in (qemu, "sh", "curl", "sha256sum", "uname", "mke2fs", "e2fsck", "debugfs", "openssl"): + if not shutil.which(tool): + raise RuntimeError(f"Missing host utility: {tool}") + timeout = float(os.environ.get("QEMU_TIMEOUT", "180")) + if not math.isfinite(timeout) or timeout <= 0: + raise RuntimeError("QEMU_TIMEOUT must be finite and positive") + disk_mib = int(os.environ.get("QEMU_DISK_MIB", "512")) + if disk_mib <= 0: + raise RuntimeError("QEMU_DISK_MIB must be positive") + results.mkdir() # Refuse stale logs/markers or existing package state. + with tempfile.TemporaryDirectory(prefix="mere-packaged-boot-") as work_dir: + root = Path(work_dir) / "root" + try: + args = ["sh", str(HERE / "prepare-root"), str(mere), str(root)] + sys.argv[3:] + with (results / "setup.log").open("wb") as output: + run(args, output, env={**os.environ, "MERE_BOOT_TESTS": "1"}) + shutil.move(str(root / "test-inputs"), results / "inputs") + inputs = results / "inputs" + profile = (inputs / "profile.kdl").read_text() + if not PACKAGES.issubset(set(re.findall(r'package "([^"]+)"', profile))): + raise RuntimeError("Recorded profile lacks required boot packages") + # Keep root directory views tied to the active system generation. + for name in ("bin", "sbin", "usr", "lib"): + (root / name).symlink_to(f"/mere/profiles/system/current/{name}") + kernel = root / "mere/profiles/system/current/boot/vmlinux" + target = os.readlink(kernel) + if not target.startswith("/mere/store/"): + raise RuntimeError("Expected packaged kernel store link") + kernel = root / target.lstrip("/") + for name in ("proc", "sys", "dev", "run", "tmp", "root", "home", "mnt/test", "var/log"): + (root / name).mkdir(parents=True, exist_ok=True) + password_hash = subprocess.check_output( + ["openssl", "passwd", "-6", "-salt", "mereboot", "-stdin"], + input=(PASSWORD + "\n").encode()).decode().strip() + write(root, "etc/passwd", "root:x:0:0:Test root:/root:/bin/sh\n") + write(root, "etc/group", (HERE / "boot-fixtures/group").read_text()) + write(root, "etc/shadow", f"root:{password_hash}:19000:0:99999:7:::\n", 0o600) + write(root, "etc/hostname", "mere-boot-test\n") + for source in (HERE / "boot-fixtures").iterdir(): + write(root, "etc/boot-test/" + source.name, source.read_text()) + write(root, "etc/dinit.d/boot-test-lifetime", + "type = scripted\ncommand = /bin/true\n" + "stop-command = /bin/sh /etc/boot-test/lifetime\ndepends-on = sysinit\n") + (root / "etc/dinit.d/boot.d/boot-test-lifetime").symlink_to("../boot-test-lifetime") + (root / "etc/dinit.d/boot.d/ttyS0").symlink_to("/usr/share/dinit.d/ttyS0") + shutil.copytree(HERE / "boot-fixtures", inputs / "boot-fixtures") + shutil.copy2(__file__, inputs / "packaged-boot.py") + shutil.copy2(HERE / "prepare-root", inputs / "prepare-root") + (inputs / "qemu.version").write_bytes(subprocess.check_output([qemu, "--version"])) + (inputs / "kernel.sha256").write_text(hashlib.sha256(kernel.read_bytes()).hexdigest() + "\n") + for case in ("boot", "reboot", "recovery"): + case_dir = results / case + case_dir.mkdir() + fstab = "tmpfs /mnt/test tmpfs defaults 0 0\n" + if case == "recovery": + fstab = "/dev/does-not-exist /mnt/test ext4 defaults 0 0\n" + write(root, "etc/fstab", fstab) + disk = Path(work_dir) / f"{case}.ext4" + with disk.open("wb") as image: + image.truncate(disk_mib * 1024 * 1024) + with (case_dir / "assemble.log").open("wb") as output: + run(["mke2fs", "-q", "-t", "ext4", "-F", "-d", str(root), str(disk)], output) + fsck(disk, case_dir / "before.fsck") + # Preserve a postmortem filesystem report even if dialogue fails. + try: + dialogue(qemu, kernel, disk, case, case_dir / "serial.log", timeout) + finally: + fsck(disk, case_dir / "after.fsck") + stops = subprocess.check_output( + ["debugfs", "-R", "cat /var/log/boot-test-stops", str(disk)], + stderr=subprocess.DEVNULL).decode() + (case_dir / "stop.log").write_text(stops) + expected = 0 if case == "recovery" else (2 if case == "reboot" else 1) + if stops.splitlines() != ["mounts-present"] * expected: + raise RuntimeError(f"{case}: service stop ordering evidence differs") + disk.unlink() + print(f"{case}: PASS", flush=True) + finally: + # Store directories can be read-only; do not follow logical symlinks. + for directory, dirs, files in os.walk(root, followlinks=False): + os.chmod(directory, os.stat(directory).st_mode | 0o700) + print(f"Results: {results}") + + +if __name__ == "__main__": + try: + main() + except (ValueError, RuntimeError, OSError, subprocess.CalledProcessError) as error: + print(f"packaged-boot: {error}", file=sys.stderr) + sys.exit(1) diff --git a/test/qemu/prepare-root b/test/qemu/prepare-root index 9f26c0cf..f9da011a 100755 --- a/test/qemu/prepare-root +++ b/test/qemu/prepare-root @@ -1,5 +1,5 @@ #!/bin/sh -# Prepare the explicit package inputs consumed by dinit-smoke. +# Prepare explicit package inputs for the disk-backed guest launchers. set -eu if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then printf 'Usage: %s ABSOLUTE_MERE_BINARY NEW_ABSOLUTE_ROOT [PREVIOUS_INPUTS_DIR]\n' "$0" >&2 @@ -8,6 +8,10 @@ fi mere=$1 root=$2 previous=${3:-} +profile=guest +if [ "${MERE_BOOT_TESTS:-0}" = 1 ]; then + profile=system +fi case $mere in /*) ;; *) printf 'Mere binary path must be absolute\n' >&2; exit 2 ;; esac case $root in /) printf 'Refusing the host root\n' >&2; exit 2 ;; /*) ;; *) printf 'Storage root must be absolute\n' >&2; exit 2 ;; esac for tool in mkdir cp curl sha256sum uname cat; do @@ -55,16 +59,27 @@ else fi cp "$inputs/config.kdl" "$root/mere/config.kdl" cp "$inputs/mere.pub" "$root/mere/keys/mere.pub" +if [ "$profile" = system ]; then + # Keep the upstream trust configuration; only select the init provider. + printf '\nsettings {\n init-provider "dinit"\n}\n' >> "$root/mere/config.kdl" + cp "$root/mere/config.kdl" "$inputs/effective-config.kdl" +fi if [ -n "$previous" ]; then - "$mere" --root "$root" profile apply "$inputs/requested-profile.kdl" -p guest + "$mere" --root "$root" profile apply "$inputs/requested-profile.kdl" -p "$profile" else - if [ "${MERE_GUEST_TESTS:-0}" = 1 ]; then + if [ "$profile" = system ]; then + "$mere" --root "$root" install -p system linux busybox dinit shadow mdevd mere-boot + elif [ "${MERE_GUEST_TESTS:-0}" = 1 ]; then "$mere" --root "$root" install -p guest linux busybox dinit zstd else "$mere" --root "$root" install -p guest linux busybox dinit fi fi -cp "$root/mere/profiles/guest/root/profile.kdl" "$inputs/profile.kdl" +if [ "$profile" = system ]; then + cp "$root/mere/profiles/system/current/profile.kdl" "$inputs/profile.kdl" +else + cp "$root/mere/profiles/guest/root/profile.kdl" "$inputs/profile.kdl" +fi sha256sum < "$inputs/config.kdl" > "$inputs/config.sha256" sha256sum < "$inputs/mere.pub" > "$inputs/key.sha256" printf 'Guest profile ready at %s\nRecorded inputs: %s\n' "$root" "$inputs"