Stop the install dying on non-UTF-8 command output (#118)
* Stop the install dying on non-UTF-8 command output efibootmgr prints what firmware gave it, and an HP ProBook's NVRAM held a legacy BBS entry whose device path was raw binary. `_read_efibootmgr` captured that with `text=True`, so Python decoded it strictly and a byte in a boot entry nothing here reads killed the whole "Installing Arch + Omarchy" phase. Two machines have hit it now, on 0x80 and 0xe0. Every text capture in the orchestrator had the same exposure — partition labels reach lsblk and blkid the same way firmware strings reach efibootmgr — so they all go through one `capture()` that replaces what it cannot decode. The file reads here already did this; the command reads were the gap. Replacing bytes has its own failure mode, and it is worse than the crash: a mangled UUID would be written into fstab and the kernel cmdline, and `_validate_pre_mounted_filesystems` reads it back from the same command, agrees with itself, and ships a machine that cannot find its root. So `require_text()` refuses a value the installed system depends on — UUIDs, device names, the btrfs device handed to mount — while the boot-entry labels stay tolerant on purpose: the decisions taken from those are advisory, and refusing a junk one would abort the install this change exists to let finish. The regression test runs a stand-in efibootmgr emitting the reported byte sequence, so it exercises the decode rather than a mock. test_keyboard.py loads the module through the package now that keyboard.py imports a sibling. Closes #116 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh <noreply@openai.com> * Say accurately why the boot-entry parsing stays tolerant The previous commit called the decisions taken from a firmware label "advisory". They are not: a label match selects entries for deletion and rewrites the boot order, and a missing one aborts validation. Staying tolerant is still right, for a reason the comment did not give — an entry's text is its label and its device path together, the junk lives in the path, and nothing mangled reaches efibootmgr from there anyway, since only four-digit ASCII boot numbers become arguments and a boot order is filtered through the entries that parsed. A label that loses a byte costs a stale entry left behind, not a broken system. The test docstring credited one machine with a fixture that carries the bytes from two. Co-Authored-By: Codex XHigh <noreply@openai.com> --------- Co-authored-by: Codex XHigh <noreply@openai.com>
This commit is contained in:
1 parent
0c1333837f
commit
df80b320cb
5 files changed
+210
-50
No files matched your search
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
"""Undecodable bytes in command output, and where tolerating them stops.
|
||||
|
||||
The orchestrator reads firmware and filesystem metadata out of commands whose
|
||||
output is not required to be UTF-8: noise in a boot entry must not stop an
|
||||
install, while a mangled identifier must stop one. These tests run stand-in
|
||||
commands emitting the bytes two HP firmwares actually put in a legacy BBS entry,
|
||||
so they exercise the decode itself rather than a mocked result.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
|
||||
# phases_impl imports the archinstall adapter at module scope, which pulls in
|
||||
# the archinstall library that only exists on the live ISO.
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter",
|
||||
types.ModuleType("orchestrator.archinstall_adapter"),
|
||||
)
|
||||
|
||||
from orchestrator import phases_impl # noqa: E402
|
||||
from orchestrator.command import capture, capture_identifier # noqa: E402
|
||||
|
||||
# Boot0000 is the entry that aborted the install: raw bytes inside a BBS device
|
||||
# path, in a legacy entry nothing in the installer reads. 0x80 and 0xe0 are the
|
||||
# bytes two different HP firmwares emitted.
|
||||
EFIBOOTMGR_OUTPUT = (
|
||||
b"BootCurrent: 0003\n"
|
||||
b"Timeout: 0 seconds\n"
|
||||
b"BootOrder: 2001,0003,0000\n"
|
||||
b"Boot0000* Notebook Hard Drive\tBBS(HD,\x80\x7f\xff\x04\xe0\x7f,)\n"
|
||||
b"Boot0003* Omarchy\tHD(1,GPT,0d9c9d4f)/File(\\EFI\\limine\\BOOTX64.EFI)\n"
|
||||
b"Boot2001* USB Drive (UEFI)\tRC\n"
|
||||
)
|
||||
|
||||
|
||||
class CommandCaptureTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bin_dir = None
|
||||
|
||||
def fake_bin(self) -> Path:
|
||||
"""A directory at the front of PATH, made once per test."""
|
||||
if self.bin_dir is None:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.bin_dir = Path(tmp.name)
|
||||
path = os.environ["PATH"]
|
||||
self.addCleanup(os.environ.__setitem__, "PATH", path)
|
||||
os.environ["PATH"] = f"{self.bin_dir}:{path}"
|
||||
return self.bin_dir
|
||||
|
||||
def fake_script(self, name: str, body: str) -> None:
|
||||
script = self.fake_bin() / name
|
||||
script.write_text(f"#!/bin/sh\n{body}\n")
|
||||
script.chmod(0o755)
|
||||
|
||||
def fake_command(self, name: str, output: bytes) -> None:
|
||||
"""A command printing exactly these bytes, undecodable ones included."""
|
||||
payload = self.fake_bin() / f"{name}.out"
|
||||
payload.write_bytes(output)
|
||||
self.fake_script(name, f'cat "{payload}"')
|
||||
|
||||
def fake_findmnt(self, source: bytes) -> None:
|
||||
"""A findmnt answering per column, so the phase gets as far as SOURCE."""
|
||||
payload = self.fake_bin() / "findmnt.source"
|
||||
payload.write_bytes(source)
|
||||
self.fake_script(
|
||||
"findmnt",
|
||||
'case "$2" in\n'
|
||||
" FSTYPE) echo btrfs ;;\n"
|
||||
" OPTIONS) echo rw,subvol=/@ ;;\n"
|
||||
f' SOURCE) cat "{payload}" ;;\n'
|
||||
"esac",
|
||||
)
|
||||
|
||||
def test_undecodable_bytes_are_replaced_rather_than_raised(self):
|
||||
self.fake_command("omarchy-not-utf8", b"before\x80after\n")
|
||||
self.assertEqual(capture(["omarchy-not-utf8"]).stdout, "before�after\n")
|
||||
|
||||
def test_efibootmgr_entries_parse_around_a_legacy_bbs_entry(self):
|
||||
self.fake_command("efibootmgr", EFIBOOTMGR_OUTPUT)
|
||||
|
||||
state = phases_impl._read_efibootmgr()
|
||||
|
||||
self.assertEqual(state["order"], ["2001", "0003", "0000"])
|
||||
self.assertEqual(phases_impl._find_label_entries(state["entries"], "Omarchy"), ["0003"])
|
||||
self.assertTrue(state["entries"]["0000"].startswith("Notebook Hard Drive"))
|
||||
|
||||
def test_a_mangled_identifier_stops_the_install_rather_than_reaching_fstab(self):
|
||||
# _validate_pre_mounted_filesystems reads the UUID back from this same
|
||||
# command, so a replaced byte would agree with itself all the way to a
|
||||
# machine that cannot find its root.
|
||||
self.fake_command("blkid", b"1a2b3c4d-\x80-dead-beef\n")
|
||||
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
phases_impl._blkid_uuid("/dev/sda2")
|
||||
|
||||
self.assertIn("the UUID of /dev/sda2 is not valid text", str(raised.exception))
|
||||
|
||||
def test_a_mangled_btrfs_device_is_never_handed_to_mount(self):
|
||||
# create_factory_snapshot mounts this device and deletes subvolumes
|
||||
# under it; a replaced byte must stop the phase before that, not turn
|
||||
# into an opaque mount failure.
|
||||
self.fake_findmnt(source=b"/dev/sda\xe02[/@]")
|
||||
ctx = types.SimpleNamespace(target=Path("/mnt"), state_dir=Path("/run/omarchy"))
|
||||
|
||||
with mock.patch.object(phases_impl, "info"), self.assertRaises(RuntimeError) as raised:
|
||||
phases_impl.create_factory_snapshot(ctx)
|
||||
|
||||
self.assertIn("is not valid text", str(raised.exception))
|
||||
|
||||
def test_a_clean_identifier_is_returned_unchanged(self):
|
||||
self.fake_command("blkid", b"1a2b3c4d-0e5f-dead-beef\n")
|
||||
self.assertEqual(
|
||||
capture_identifier(["blkid"], "the UUID"), "1a2b3c4d-0e5f-dead-beef"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,18 +1,16 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
|
||||
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
|
||||
KEYBOARD = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader
|
||||
SPEC.loader.exec_module(KEYBOARD)
|
||||
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
|
||||
from orchestrator import keyboard as KEYBOARD # noqa: E402
|
||||
|
||||
# The layout list lives in the Omarchy runtime now, shared verbatim with the
|
||||
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
|
||||
|
||||
Reference in new issue
Block a user