test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
"""Always-on unit tests for the QEMU harness logic.
|
|
|
|
|
|
|
|
|
|
No QEMU, root, or network: they exercise the pure command builders and guard
|
|
|
|
|
the root build script against drifting away from what the harness expects.
|
|
|
|
|
Run as part of the normal suite.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from lim import config as lim_config
|
|
|
|
|
from tests.e2e.qemu import boot_unlock, config, harness, tor_net
|
|
|
|
|
from tests.e2e.qemu.config import EXPECTED_HOOKS_ORDER, QemuSpec
|
|
|
|
|
|
|
|
|
|
BUILD_SCRIPT = Path(__file__).resolve().parents[2] / "tests/e2e/qemu/build_image.sh"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spec(**overrides) -> QemuSpec:
|
|
|
|
|
base = {
|
|
|
|
|
"arch": "x86_64",
|
|
|
|
|
"work_dir": Path("/work"),
|
|
|
|
|
"image_path": Path("/work/image.raw"),
|
|
|
|
|
"kernel_path": Path("/work/kernel"),
|
|
|
|
|
"initramfs_path": Path("/work/initramfs"),
|
|
|
|
|
"luks_uuid": "1111-UUID",
|
|
|
|
|
"mapper_name": "cryptroot",
|
|
|
|
|
"passphrase": "secret",
|
|
|
|
|
"ssh_key_path": Path("/work/unlock_key"),
|
|
|
|
|
"onion_address": "abcd.onion",
|
|
|
|
|
"socks_port": 9052,
|
|
|
|
|
}
|
|
|
|
|
base.update(overrides)
|
|
|
|
|
return QemuSpec(**base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestKernelCmdline:
|
|
|
|
|
def test_unlocks_the_luks_root_over_dhcp(self):
|
|
|
|
|
line = config.kernel_cmdline(_spec())
|
|
|
|
|
assert "cryptdevice=UUID=1111-UUID:cryptroot" in line
|
|
|
|
|
assert "root=/dev/mapper/cryptroot" in line
|
|
|
|
|
# Explicit device in the ip= form, else netconf loops on "No such device".
|
|
|
|
|
assert ":eth0:dhcp" in line
|
|
|
|
|
assert "console=ttyS0" in line
|
|
|
|
|
|
|
|
|
|
def test_serial_console_is_last_so_marker_reaches_serial_log(self):
|
|
|
|
|
line = config.kernel_cmdline(_spec())
|
|
|
|
|
assert line.index("console=tty0") < line.index("console=ttyS0")
|
|
|
|
|
|
|
|
|
|
def test_aarch64_uses_amba_serial(self):
|
|
|
|
|
assert "console=ttyAMA0" in config.kernel_cmdline(_spec(arch="aarch64"))
|
|
|
|
|
|
2026-07-22 17:03:16 +02:00
|
|
|
def test_debian_uses_crypttab_not_cryptdevice(self):
|
|
|
|
|
# Debian's cryptsetup-initramfs reads /etc/crypttab, so the cmdline
|
|
|
|
|
# carries only root=/dev/mapper/<name>, no cryptdevice= param.
|
|
|
|
|
line = config.kernel_cmdline(_spec(os_family="debian"))
|
|
|
|
|
assert "cryptdevice=" not in line
|
|
|
|
|
assert "root=/dev/mapper/cryptroot" in line
|
|
|
|
|
assert ":eth0:dhcp" in line
|
|
|
|
|
assert line.index("console=tty0") < line.index("console=ttyS0")
|
|
|
|
|
|
test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
|
|
|
|
|
class TestQemuArgv:
|
|
|
|
|
def test_x86_uses_virtio_net_and_direct_kernel_boot(self):
|
|
|
|
|
argv = config.qemu_argv(_spec())
|
|
|
|
|
assert argv[0] == "qemu-system-x86_64"
|
|
|
|
|
assert "virtio-net-pci,netdev=net0" in argv
|
|
|
|
|
assert "-kernel" in argv
|
|
|
|
|
assert "/work/kernel" in argv
|
|
|
|
|
assert "-initrd" in argv
|
|
|
|
|
assert "/work/initramfs" in argv
|
|
|
|
|
assert "file=/work/image.raw,if=virtio,format=raw" in argv
|
|
|
|
|
assert f"file:{_spec().serial_log}" in argv
|
|
|
|
|
assert "user,id=net0" in argv
|
|
|
|
|
|
|
|
|
|
def test_aarch64_uses_virt_machine_and_net_device(self):
|
|
|
|
|
argv = config.qemu_argv(_spec(arch="aarch64"))
|
|
|
|
|
assert argv[0] == "qemu-system-aarch64"
|
|
|
|
|
assert "virt" in argv
|
|
|
|
|
assert "virtio-net-device,netdev=net0" in argv
|
|
|
|
|
|
|
|
|
|
def test_kvm_accel_is_emitted_when_requested(self):
|
|
|
|
|
assert "kvm" in config.qemu_argv(_spec(accel="kvm"))
|
|
|
|
|
assert "kvm" not in config.qemu_argv(_spec(accel="tcg"))
|
|
|
|
|
|
|
|
|
|
def test_unknown_arch_raises(self):
|
|
|
|
|
with pytest.raises(ValueError, match="Unsupported arch"):
|
|
|
|
|
config.qemu_argv(_spec(arch="riscv64"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestQemuBinary:
|
|
|
|
|
def test_derives_binary_per_arch(self):
|
|
|
|
|
assert config.qemu_binary("x86_64") == "qemu-system-x86_64"
|
|
|
|
|
assert config.qemu_binary("aarch64") == "qemu-system-aarch64"
|
|
|
|
|
|
|
|
|
|
def test_unknown_arch_raises(self):
|
|
|
|
|
with pytest.raises(ValueError, match="Unsupported arch"):
|
|
|
|
|
config.qemu_binary("riscv64")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSshInvocation:
|
|
|
|
|
def test_proxy_command_routes_through_socks5(self):
|
|
|
|
|
proxy = config.ssh_proxy_command(9052)
|
|
|
|
|
assert "--proxy 127.0.0.1:9052" in proxy
|
|
|
|
|
assert "--proxy-type socks5" in proxy
|
|
|
|
|
|
|
|
|
|
def test_ssh_argv_targets_onion_via_key_and_proxy(self):
|
|
|
|
|
argv = config.ssh_argv(_spec())
|
|
|
|
|
assert "root@abcd.onion" in argv
|
|
|
|
|
assert "-i" in argv
|
|
|
|
|
assert "/work/unlock_key" in argv
|
|
|
|
|
assert any("ProxyCommand=" in part for part in argv)
|
|
|
|
|
assert "StrictHostKeyChecking=no" in argv
|
|
|
|
|
|
2026-07-22 17:03:16 +02:00
|
|
|
def test_arch_session_runs_no_remote_command(self):
|
|
|
|
|
assert config.ssh_argv(_spec())[-1] == "root@abcd.onion"
|
|
|
|
|
|
|
|
|
|
def test_debian_session_runs_cryptroot_unlock(self):
|
|
|
|
|
argv = config.ssh_argv(_spec(unlock_command="cryptroot-unlock"))
|
|
|
|
|
assert argv[-1] == "cryptroot-unlock"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDirectTransport:
|
|
|
|
|
def test_qemu_forwards_a_host_port_to_dropbear(self):
|
|
|
|
|
argv = config.qemu_argv(_spec(direct_ssh_port=2222))
|
|
|
|
|
assert "user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22" in argv
|
|
|
|
|
|
|
|
|
|
def test_tor_transport_adds_no_hostfwd(self):
|
|
|
|
|
argv = config.qemu_argv(_spec())
|
|
|
|
|
assert "user,id=net0" in argv
|
|
|
|
|
assert not any("hostfwd" in part for part in argv)
|
|
|
|
|
|
|
|
|
|
def test_ssh_argv_direct_connects_to_forwarded_port(self):
|
|
|
|
|
argv = config.ssh_argv(_spec(direct_ssh_port=2222))
|
|
|
|
|
assert "root@127.0.0.1" in argv
|
|
|
|
|
assert "-p" in argv
|
|
|
|
|
assert "2222" in argv
|
|
|
|
|
assert not any("ProxyCommand" in part for part in argv)
|
|
|
|
|
|
test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
|
|
|
|
|
class TestBuildScriptStaysAligned:
|
|
|
|
|
"""Guards: the root build script must match what the harness assumes."""
|
|
|
|
|
|
|
|
|
|
def _script(self) -> str:
|
|
|
|
|
return BUILD_SCRIPT.read_text()
|
|
|
|
|
|
|
|
|
|
def test_hooks_place_tor_between_netconf_and_dropbear(self):
|
2026-07-22 17:03:16 +02:00
|
|
|
hooks_line = next(line for line in self._script().splitlines() if line.startswith("HOOKS="))
|
test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
positions = [hooks_line.index(hook) for hook in EXPECTED_HOOKS_ORDER]
|
|
|
|
|
assert positions == sorted(positions), hooks_line
|
|
|
|
|
|
|
|
|
|
def test_installs_the_real_lim_initcpio_files(self):
|
|
|
|
|
script = self._script()
|
|
|
|
|
for name in ("tor_install", "tor_hook", "torrc"):
|
|
|
|
|
assert f"$INITCPIO_SRC/{name}" in script
|
|
|
|
|
assert "etc/dropbear/root_key" in script
|
|
|
|
|
|
|
|
|
|
def test_uses_virtio_net_and_emits_boot_marker(self):
|
|
|
|
|
script = self._script()
|
|
|
|
|
assert "virtio_net" in script
|
|
|
|
|
assert config.BOOT_OK_MARKER in script
|
|
|
|
|
|
|
|
|
|
def test_mounts_devpts_before_dropbear_for_pty(self):
|
2026-07-22 17:03:16 +02:00
|
|
|
hooks_line = next(line for line in self._script().splitlines() if line.startswith("HOOKS="))
|
test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
assert "ptsmount" in hooks_line
|
|
|
|
|
assert hooks_line.index("ptsmount") < hooks_line.index("dropbear")
|
|
|
|
|
assert "mount -t devpts" in self._script()
|
|
|
|
|
|
|
|
|
|
def test_real_initcpio_source_directory_exists(self):
|
|
|
|
|
assert (lim_config.CONFIGURATION_PATH / "initcpio" / "tor_hook").is_file()
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 17:03:16 +02:00
|
|
|
class TestDebianBuildScript:
|
|
|
|
|
"""Guards for the debootstrap build script and the OS dispatch."""
|
|
|
|
|
|
|
|
|
|
def _script(self) -> str:
|
|
|
|
|
path = Path(__file__).resolve().parents[2] / "tests/e2e/qemu/build_image_debian.sh"
|
|
|
|
|
return path.read_text()
|
|
|
|
|
|
|
|
|
|
def test_installs_the_real_initramfs_tools_hooks(self):
|
|
|
|
|
script = self._script()
|
|
|
|
|
for name in ("tor_hook", "tor_premount", "tor_bottom", "torrc"):
|
|
|
|
|
assert f"$HOOK_SRC/{name}" in script
|
|
|
|
|
assert "etc/dropbear/initramfs/authorized_keys" in script
|
|
|
|
|
|
|
|
|
|
def test_crypttab_uses_initramfs_option_and_emits_marker(self):
|
|
|
|
|
script = self._script()
|
|
|
|
|
assert "none luks,initramfs" in script
|
|
|
|
|
assert config.BOOT_OK_MARKER in script
|
|
|
|
|
|
|
|
|
|
def test_harness_maps_os_family_to_script_and_unlock_command(self):
|
|
|
|
|
assert harness._BUILD_SCRIPT["debian"] == "build_image_debian.sh"
|
|
|
|
|
assert harness._BUILD_SCRIPT["arch"] == "build_image.sh"
|
|
|
|
|
assert harness._UNLOCK_COMMAND["debian"] == "cryptroot-unlock"
|
|
|
|
|
assert harness._UNLOCK_COMMAND["arch"] == ""
|
|
|
|
|
|
|
|
|
|
def test_real_initramfs_tools_source_directory_exists(self):
|
|
|
|
|
hook = lim_config.CONFIGURATION_PATH / "initramfs-tools" / "tor_premount"
|
|
|
|
|
assert hook.is_file()
|
|
|
|
|
|
|
|
|
|
|
test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.
The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.
The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
|
|
|
class TestOrchestratorGlue:
|
|
|
|
|
def test_load_spec_reads_image_env(self, tmp_path):
|
|
|
|
|
(tmp_path / "image.env").write_text(
|
|
|
|
|
"LUKS_UUID=abc-123\n"
|
|
|
|
|
"MAPPER_NAME=cryptroot\n"
|
|
|
|
|
"ONION_ADDRESS=xyz.onion\n"
|
|
|
|
|
"IMAGE=/w/image.raw\n"
|
|
|
|
|
"KERNEL=/w/kernel\n"
|
|
|
|
|
"INITRAMFS=/w/initramfs\n"
|
|
|
|
|
)
|
|
|
|
|
cfg = harness.HarnessConfig(repo_root=Path("/repo"), work_dir=tmp_path)
|
|
|
|
|
spec = harness._load_spec(cfg, ssh_key=tmp_path / "k", socks_port=9052)
|
|
|
|
|
assert spec.luks_uuid == "abc-123"
|
|
|
|
|
assert spec.onion_address == "xyz.onion"
|
|
|
|
|
assert spec.image_path == Path("/w/image.raw")
|
|
|
|
|
assert spec.socks_port == 9052
|
|
|
|
|
|
|
|
|
|
def test_env_parser_ignores_comments_and_blanks(self, tmp_path):
|
|
|
|
|
env = tmp_path / "image.env"
|
|
|
|
|
env.write_text("# a comment\n\nKEY=value\n")
|
|
|
|
|
assert boot_unlock.parse_env_file(env) == {"KEY": "value"}
|
|
|
|
|
|
|
|
|
|
def test_choose_accel_falls_back_to_tcg_for_foreign_arch(self):
|
|
|
|
|
assert harness.choose_accel("riscv64") == "tcg"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestChutneyParsing:
|
|
|
|
|
def test_parse_socks_port_picks_first_nonzero(self, tmp_path):
|
|
|
|
|
torrc = tmp_path / "torrc"
|
|
|
|
|
torrc.write_text("SocksPort 0\nSocksPort 9008\n")
|
|
|
|
|
assert tor_net._parse_socks_port(torrc) == 9008
|
|
|
|
|
|
|
|
|
|
def test_find_client_torrc_skips_relays(self, tmp_path):
|
|
|
|
|
(tmp_path / "000a").mkdir()
|
|
|
|
|
(tmp_path / "000a" / "torrc").write_text("SocksPort 0\n")
|
|
|
|
|
(tmp_path / "001c").mkdir()
|
|
|
|
|
(tmp_path / "001c" / "torrc").write_text("SocksPort 9010\n")
|
|
|
|
|
found = tor_net._find_client_torrc(tmp_path)
|
|
|
|
|
assert found == tmp_path / "001c" / "torrc"
|