aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_switch_udev_hook.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps/test_switch_udev_hook.py')
-rw-r--r--tests/installer-steps/test_switch_udev_hook.py229
1 files changed, 229 insertions, 0 deletions
diff --git a/tests/installer-steps/test_switch_udev_hook.py b/tests/installer-steps/test_switch_udev_hook.py
new file mode 100644
index 0000000..f6d1dc2
--- /dev/null
+++ b/tests/installer-steps/test_switch_udev_hook.py
@@ -0,0 +1,229 @@
+"""Test the udev -> systemd initramfs hook swap.
+
+configure_initramfs_hook swaps the `udev` initramfs hook for `systemd` so fsck
+output routes through systemd. mkinitcpio's `systemd` hook symlinks /init to
+systemd, which never executes the ash `run_hook` scripts the busybox init
+drives -- so any busybox-only hook left in HOOKS is installed into the image
+and then never runs.
+
+`encrypt` is the boot-critical member of that set: it is what unlocks a LUKS
+root, and its `cryptdevice=` cmdline parameter (which merge_grub_cmdline
+already treats as boot-critical) is read by nothing else. mkinitcpio has no
+conflict check, so `mkinitcpio -P` succeeds, archsetup reports success, and the
+machine fails to unlock its root at the next boot. The systemd equivalent is
+`sd-encrypt` driven by `rd.luks.*`, and converting between the two means
+rewriting the kernel cmdline against the volume's UUID -- so the step refuses
+the swap rather than attempting a migration.
+
+Method: sed-extract switch_udev_hook_to_systemd + hooks_need_busybox_init from
+the real `archsetup`, point them at a temp mkinitcpio.conf, and fake
+backup_system_file/display/error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_switch_udev_hook
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+# The two HOOKS lines Arch's own mkinitcpio.conf documents for an encrypted
+# root -- the busybox pairing and the systemd pairing. They are the fixtures
+# that matter, because the bug is turning the first into neither.
+BUSYBOX_ENCRYPT = (
+ "HOOKS=(base udev microcode modconf keyboard keymap consolefont block "
+ "mdadm_udev encrypt filesystems fsck)\n"
+)
+SYSTEMD_ENCRYPT = (
+ "HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole "
+ "sd-encrypt block filesystems fsck)\n"
+)
+PLAIN_UDEV = "HOOKS=(base udev autodetect microcode modconf block filesystems fsck)\n"
+
+
+def run(conf_body, missing_conf=False):
+ with tempfile.TemporaryDirectory() as d:
+ conf = os.path.join(d, "mkinitcpio.conf")
+ if not missing_conf:
+ with open(conf, "w") as f:
+ f.write(conf_body)
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ echo "DISPLAY: $2"; }}
+ backup_system_file() {{ echo "BACKUP: $1" >> "{d}/backup.log"; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^hooks_need_busybox_init() {{/,/^}}/p;/^switch_udev_hook_to_systemd() {{/,/^}}/p' "{ARCHSETUP}")
+ switch_udev_hook_to_systemd "{conf}"
+ echo "RC=$?"
+ echo "CONF:[$(cat "{conf}" 2>/dev/null)]"
+ [ -f "{d}/backup.log" ] && cat "{d}/backup.log"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ """The step's exact return code. Substring-matching "RC=1" in stdout would
+ also match "RC=127" (the code bash gives a missing function), so a broken
+ extraction would read as a passing test."""
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class SwitchUdevHookToSystemd(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_plain_udev_is_swapped_and_reports_change(self):
+ r = run(PLAIN_UDEV)
+ self.assertIn("HOOKS=(base systemd autodetect", r.stdout)
+ self.assertNotIn("udev", r.stdout.split("CONF:[")[1])
+ self.assertEqual(rc_of(r), 0, "a conf that changed must report success")
+ self.assertIn("BACKUP:", r.stdout, "the conf must be backed up before the edit")
+
+ def test_already_systemd_is_left_alone_and_reports_no_change(self):
+ body = "HOOKS=(base systemd autodetect block filesystems fsck)\n"
+ r = run(body)
+ self.assertIn(body.strip(), r.stdout)
+ self.assertEqual(rc_of(r), 1,
+ "an unchanged conf must not report a change (no needless rebuild)")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_busybox_encrypt_hook_blocks_the_swap(self):
+ # THE BUG: swapping here leaves `encrypt` under a systemd init that
+ # never runs it, so the LUKS root never unlocks and the machine will
+ # not boot.
+ r = run(BUSYBOX_ENCRYPT)
+ self.assertIn(" udev ", r.stdout, "udev must survive next to a busybox encrypt hook")
+ self.assertIn(" encrypt ", r.stdout)
+ self.assertNotIn("systemd", r.stdout.split("CONF:[")[1])
+ self.assertEqual(rc_of(r), 1)
+ self.assertNotIn("BACKUP:", r.stdout, "a declined swap must not touch the file")
+
+ def test_keymap_and_consolefont_become_sd_vconsole(self):
+ """Both are busybox-only run_hook scripts, so a systemd initramfs
+ installs them and never executes them. sd-vconsole is the systemd
+ equivalent, and it ships with mkinitcpio. Without this the early console
+ keeps the default font and keymap until systemd-vconsole-setup runs in
+ the real root -- cosmetic on a machine that just boots, and not cosmetic
+ at all on one that asks for a passphrase with a non-US layout."""
+ r = run("HOOKS=(base udev keymap consolefont block filesystems fsck)\n")
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn("sd-vconsole", conf)
+ self.assertNotIn("keymap", conf)
+ self.assertNotIn("consolefont", conf)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_keymap_alone_becomes_sd_vconsole(self):
+ r = run("HOOKS=(base udev keymap block filesystems)\n")
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn("sd-vconsole", conf)
+ self.assertNotIn("keymap", conf)
+
+ def test_consolefont_alone_becomes_sd_vconsole(self):
+ r = run("HOOKS=(base udev consolefont block filesystems)\n")
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn("sd-vconsole", conf)
+ self.assertNotIn("consolefont", conf)
+
+ def test_the_two_collapse_into_one_sd_vconsole(self):
+ r = run("HOOKS=(base udev keymap block consolefont filesystems)\n")
+ conf = r.stdout.split("CONF:[")[1].split("]")[0]
+ self.assertEqual(conf.count("sd-vconsole"), 1)
+
+ def test_an_existing_sd_vconsole_is_not_duplicated(self):
+ r = run("HOOKS=(base udev sd-vconsole keymap block filesystems)\n")
+ conf = r.stdout.split("CONF:[")[1].split("]")[0]
+ self.assertEqual(conf.count("sd-vconsole"), 1)
+ self.assertNotIn("keymap", conf)
+
+ def test_a_conf_with_neither_hook_is_unchanged_apart_from_the_swap(self):
+ r = run("HOOKS=(base udev block filesystems fsck)\n")
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertNotIn("sd-vconsole", conf)
+ self.assertIn("systemd", conf)
+
+ def test_a_refused_swap_leaves_keymap_alone(self):
+ # No swap means busybox init still runs, and keymap still works there.
+ r = run("HOOKS=(base udev keymap encrypt filesystems)\n")
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn("keymap", conf)
+ self.assertNotIn("sd-vconsole", conf)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_tab_separated_encrypt_hook_blocks_the_swap(self):
+ # The token class was [( ] / [ )], which is a literal space and does
+ # not include a tab. mkinitcpio's HOOKS is a bash array literal, and
+ # bash word-splits on IFS -- tabs included -- so a tab-separated HOOKS
+ # line is valid, loads correctly, and slipped straight past the guard
+ # into the exact unbootable state it exists to prevent.
+ r = run("HOOKS=(base udev block\tencrypt\tfilesystems fsck)\n")
+ self.assertEqual(rc_of(r), 1, "a tab-separated encrypt must block the swap")
+ self.assertIn("encrypt", r.stdout)
+ self.assertNotIn("systemd", r.stdout.split("CONF:[")[1])
+ self.assertNotIn("BACKUP:", r.stdout)
+
+ def test_tab_separated_sd_encrypt_is_still_not_the_busybox_hook(self):
+ # Widening the class must not widen what counts as the busybox hook:
+ # sd-encrypt IS the systemd one and has to keep swapping.
+ r = run("HOOKS=(base\tsystemd\tsd-encrypt\tfilesystems)\n")
+ self.assertNotIn("BACKUP:", r.stdout) # already systemd, nothing to do
+ self.assertEqual(rc_of(r), 1)
+
+ def test_tab_separated_udev_without_encrypt_still_swaps(self):
+ r = run("HOOKS=(base\tudev\tblock\tfilesystems\tfsck)\n")
+ self.assertEqual(rc_of(r), 0, "a tab-separated conf with no encrypt must swap")
+ self.assertIn("systemd", r.stdout.split("CONF:[")[1])
+
+ def test_plymouth_encrypt_hook_blocks_the_swap(self):
+ r = run("HOOKS=(base udev block plymouth-encrypt filesystems)\n")
+ self.assertIn(" udev ", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_sd_encrypt_is_not_mistaken_for_the_busybox_hook(self):
+ # sd-encrypt IS the systemd hook -- it must not be read as a reason to
+ # refuse. (No udev here, so the swap is a no-op either way; what this
+ # pins is that the substring match does not over-trigger.)
+ r = run(SYSTEMD_ENCRYPT)
+ self.assertIn("sd-encrypt", r.stdout)
+ self.assertNotIn("HOOKS carries encrypt", r.stdout)
+
+ def test_encrypt_as_last_token_is_detected(self):
+ r = run("HOOKS=(base udev block encrypt)\n")
+ self.assertIn(" udev ", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_encrypt_as_first_token_is_detected(self):
+ r = run("HOOKS=(encrypt udev block)\n")
+ self.assertIn("(encrypt udev block)", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_only_the_hooks_line_is_rewritten(self):
+ # A commented example line mentioning udev must survive untouched.
+ body = "# HOOKS=(base udev block filesystems fsck)\n" + PLAIN_UDEV
+ r = run(body)
+ self.assertIn("# HOOKS=(base udev block filesystems fsck)", r.stdout)
+ self.assertIn("HOOKS=(base systemd autodetect", r.stdout)
+
+ # ------------------------------------------------------------- error ----
+ def test_conf_without_a_hooks_line_is_a_no_op(self):
+ r = run("MODULES=(nvme)\n")
+ self.assertEqual(rc_of(r), 1)
+ self.assertIn("MODULES=(nvme)", r.stdout)
+
+ def test_missing_conf_is_a_no_op_not_a_crash(self):
+ r = run("", missing_conf=True)
+ self.assertEqual(rc_of(r), 1)
+ self.assertEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()