aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-24 12:19:04 -0500
committerCraig Jennings <c@cjennings.net>2026-07-24 12:19:04 -0500
commit3becfac66dc569e71d0f6085fb56e92cfa6d626a (patch)
tree1e27bfb5e8135fe3239728aa109cf67448f87cd2 /tests/installer-steps
parent40216e7c8e3c848190cbef1d3d1eebc8b5bc2136 (diff)
downloadarchsetup-3becfac66dc569e71d0f6085fb56e92cfa6d626a.tar.gz
archsetup-3becfac66dc569e71d0f6085fb56e92cfa6d626a.zip
fix(installer): eight fixes from an overnight bug-hunt and its review
I squashed these because the per-bug reasoning lives in todo.org, which this commit carries. Two could cost a machine. configure_initramfs_hook swapped the udev hook for systemd on a LUKS root, leaving a standalone encrypt hook under an init that never runs it. The rebuild succeeds and the installer exits clean, then the root won't unlock at the next boot. trim_firmware ran pacman -Rdd against twelve firmware packages behind a DMI gate reading product_name, where "Framework" never appears. That left it dead on the hardware it targets, and dangerous to fix the obvious way: this machine is a Framework Desktop whose Ryzen iGPU needs the amdgpu firmware. It refuses on PCI modalias evidence now. wipedisk discarded before it checked. blkdiscard ran with -f, which disables the exclusive open, so picking the wrong disk destroyed a live filesystem and then reported that nothing had happened. Four more are smaller. The NVIDIA preflight aborted dwm and headless installs over a driver floor they never need. zfs-replicate exited 0 after every dataset failed. Unattended installs blocked on two prompts, and the first fix for that inherited a [Y/n] default into passwordless console login. A fresh install left the dotfiles repo permanently dirty. The review found a pattern worth more than any single fix. Helpers had thorough tests and none proved they were called. Deleting the call left five suites green, including the guard on that pacman -Rdd. CALL_SITES now pins nine caller/callee pairs. The suite runs 341 tests at exit 0, with no new shellcheck findings. I proved every guard by deleting it and watching the intended test go red.
Diffstat (limited to 'tests/installer-steps')
-rw-r--r--tests/installer-steps/test_configure_autologin.py218
-rw-r--r--tests/installer-steps/test_framework_firmware_trim.py233
-rw-r--r--tests/installer-steps/test_mark_volatile_configs.py216
-rw-r--r--tests/installer-steps/test_orchestrators.py58
-rw-r--r--tests/installer-steps/test_select_locale.py160
-rw-r--r--tests/installer-steps/test_switch_udev_hook.py178
6 files changed, 1063 insertions, 0 deletions
diff --git a/tests/installer-steps/test_configure_autologin.py b/tests/installer-steps/test_configure_autologin.py
new file mode 100644
index 0000000..b4bc1c4
--- /dev/null
+++ b/tests/installer-steps/test_configure_autologin.py
@@ -0,0 +1,218 @@
+"""Tests for configure_autologin in the archsetup installer.
+
+configure_autologin decides whether tty1 gets an agetty --autologin drop-in.
+Three inputs decide it: --autologin / --no-autologin (or AUTOLOGIN=yes/no in
+the config file), which are explicit; and, when neither is set, an auto-detect
+branch that prompts -- but only on an encrypted root, where the user has
+already authenticated at boot.
+
+That prompt is the reason these tests exist. --config-file is the documented
+unattended mode and AUTOLOGIN is optional in archsetup.conf.example, so an
+unattended install of an encrypted machine reaches an interactive read with
+nobody there to answer it. The step runs from boot_ux, the last step in STEPS,
+so it blocks at the end of a 40-60 minute run. The prompt is advisory (it
+carries its own [Y/n] default), so unattended must answer it rather than block
+-- the same ruling nvidia_preflight already makes for its rc-10 prompt.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), with is_encrypted_root stubbed to a chosen
+answer and the drop-in written under a temp dir.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_autologin
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+class ConfigureAutologinHarness(unittest.TestCase):
+ """Source configure_autologin out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="autologin-test-")
+ self.dropin_dir = os.path.join(self.tmp, "getty@tty1.service.d")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^configure_autologin() {/,/^}/p' \"$ARCHSETUP\")\n"
+ # The step's only other collaborators: a display helper and the
+ # encryption probe. Both stubbed so the body is what runs.
+ 'display() { echo "DISPLAY:$2"; }\n'
+ "is_encrypted_root() { return \"${STUB_ENCRYPTED_RC:-1}\"; }\n"
+ 'configure_autologin "$AUTOLOGIN_DIR"\n'
+ 'echo "RETURNED=$?"\n'
+ # Whatever the prompt did not consume is still on stdin.
+ 'if IFS= read -r leftover; then echo "LEFTOVER:$leftover"; '
+ 'else echo "LEFTOVER:"; fi\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_step(self, enable_autologin="", encrypted=False, config_file="",
+ stdin="", username="testuser"):
+ env = dict(os.environ)
+ env["enable_autologin"] = enable_autologin
+ env["config_file"] = config_file
+ env["username"] = username
+ env["AUTOLOGIN_DIR"] = self.dropin_dir
+ env["STUB_ENCRYPTED_RC"] = "0" if encrypted else "1"
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP],
+ input=stdin, capture_output=True, text=True, env=env,
+ )
+
+ def dropin(self):
+ """The written drop-in's contents, or None when it wasn't written."""
+ path = os.path.join(self.dropin_dir, "autologin.conf")
+ if not os.path.exists(path):
+ return None
+ with open(path) as f:
+ return f.read()
+
+ def assertReturnedZero(self, r):
+ self.assertIn("RETURNED=0", r.stdout,
+ "step did not return 0; stdout=%r stderr=%r"
+ % (r.stdout, r.stderr))
+
+ # ---------------------------------------------------------- normal ----
+ def test_explicit_true_writes_dropin_without_prompting(self):
+ r = self.run_step(enable_autologin="true", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_explicit_false_writes_nothing_without_prompting(self):
+ r = self.run_step(enable_autologin="false", encrypted=True,
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_unencrypted_root_skips_silently(self):
+ r = self.run_step(encrypted=False, stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # -------------------------------------------------------- boundary ----
+ def test_autodetect_encrypted_interactive_yes_writes_dropin(self):
+ # bash emits `read -p`'s prompt only when stdin is a terminal, so the
+ # prompt text is unassertable here. What is assertable is that the read
+ # consumed exactly the answer line and left the rest of stdin alone.
+ r = self.run_step(encrypted=True, stdin="y\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_encrypted_bare_enter_takes_the_yes_default(self):
+ r = self.run_step(encrypted=True, stdin="\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_config_file_does_not_block_on_the_prompt(self):
+ # --config-file is the documented unattended mode and AUTOLOGIN is
+ # optional in archsetup.conf.example, so this is the ordinary
+ # unattended install of an encrypted machine. It must not read stdin.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_leaves_autologin_off(self):
+ # A prompt default is calibrated for someone who can override it in the
+ # moment. Unattended there is no override, so the default stops being
+ # advisory and becomes policy -- and this policy is passwordless console
+ # login. "Encrypted root means already authenticated" holds only at
+ # boot: it does not hold for a stolen powered-on machine, where tty1 is
+ # one Ctrl-Alt-F1 away and a screen locker does not lock the console.
+ #
+ # The error costs are asymmetric. Wrongly ON is a silent authentication
+ # downgrade nobody notices. Wrongly OFF is one password prompt, noticed
+ # immediately, fixed with a config key that already exists.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+
+ def test_unattended_says_why_it_left_autologin_off(self):
+ # Silently declining is its own trap: the user gets a login prompt they
+ # did not expect and no idea which knob controls it.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf")
+ self.assertIn("AUTOLOGIN", r.stdout)
+
+ def test_unattended_explicit_yes_still_enables_it(self):
+ # Declining by default must not make the feature unreachable
+ # unattended. AUTOLOGIN=yes is the opt-in and it still works.
+ r = self.run_step(enable_autologin="true", encrypted=True,
+ config_file="/etc/archsetup.conf", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_explicit_no_still_wins_over_the_default(self):
+ # Unattended does not mean "always autologin" -- AUTOLOGIN=no is an
+ # answer, and the auto-detect default must not override it.
+ r = self.run_step(enable_autologin="false", encrypted=True,
+ config_file="/etc/archsetup.conf", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_unencrypted_root_still_writes_nothing(self):
+ r = self.run_step(encrypted=False, config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # ----------------------------------------------------------- error ----
+ def test_autodetect_encrypted_interactive_no_writes_nothing(self):
+ r = self.run_step(encrypted=True, stdin="n\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_encrypted_interactive_capital_no_writes_nothing(self):
+ r = self.run_step(encrypted=True, stdin="No\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_dropin_overrides_the_packaged_execstart(self):
+ # A drop-in that adds a second ExecStart without clearing the first is
+ # a unit systemd refuses to start -- tty1 would have no getty at all.
+ r = self.run_step(enable_autologin="true")
+ self.assertReturnedZero(r)
+ body = self.dropin() or ""
+ self.assertIn("ExecStart=\n", body)
+ self.assertLess(body.index("ExecStart=\n"),
+ body.index("ExecStart=-/sbin/agetty"))
+
+ def test_closed_stdin_leaves_autologin_off(self):
+ # The same "nobody can answer" state reached without --config-file: a
+ # piped or redirected stdin at EOF. Pre-fix the read failed, response
+ # was empty, and `case *` enabled autologin silently.
+ r = self.run_step(encrypted=True, stdin="")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+
+ def test_closed_stdin_says_why(self):
+ r = self.run_step(encrypted=True, stdin="")
+ self.assertIn("stdin closed", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_framework_firmware_trim.py b/tests/installer-steps/test_framework_firmware_trim.py
new file mode 100644
index 0000000..cdb6a60
--- /dev/null
+++ b/tests/installer-steps/test_framework_firmware_trim.py
@@ -0,0 +1,233 @@
+"""Tests for is_framework_intel_laptop, the gate on trim_firmware.
+
+trim_firmware runs `pacman -Rdd` against twelve linux-firmware packages,
+keeping only the three a Framework 13 Intel needs (intel, atheros, realtek).
+It is the most destructive conditional in the installer, so the condition is
+what these tests pin.
+
+The gate has to be exact in both directions. Too narrow and the step is dead
+code: "framework" lives in DMI's sys_vendor, never in product_name, so a gate
+reading product_name alone matches no Framework machine at all. Too wide and it
+removes linux-firmware-amdgpu from a Framework Desktop, whose Ryzen AI Max iGPU
+needs it to bring up a display. Both machines are real: velox is
+Framework/"Laptop (13th Gen Intel Core)" and must trim; ratio is
+Framework/"Desktop (AMD Ryzen AI Max 300 Series)" and must not.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against a fixture DMI directory.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_framework_firmware_trim
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+class FrameworkIntelGateHarness(unittest.TestCase):
+ """Source is_framework_intel_laptop out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="fw-trim-test-")
+ self.dmi = os.path.join(self.tmp, "dmi")
+ os.mkdir(self.dmi)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^is_framework_intel_laptop() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'if is_framework_intel_laptop "$DMI_DIR"; then echo "TRIM=yes"; '
+ 'else echo "TRIM=no"; fi\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def dmi_write(self, **fields):
+ for name, value in fields.items():
+ with open(os.path.join(self.dmi, name), "w") as f:
+ f.write(value + "\n")
+
+ def trims(self, dmi_dir=None):
+ env = dict(os.environ)
+ env["DMI_DIR"] = self.dmi if dmi_dir is None else dmi_dir
+ r = subprocess.run(["bash", self.wrapper, ARCHSETUP],
+ capture_output=True, text=True, env=env)
+ if "TRIM=yes" in r.stdout:
+ return True
+ if "TRIM=no" in r.stdout:
+ return False
+ self.fail("no TRIM line; stdout=%r stderr=%r" % (r.stdout, r.stderr))
+
+ # ---------------------------------------------------------- normal ----
+ def test_velox_framework_13_intel_trims(self):
+ # The exact strings read off velox on 2026-07-24.
+ self.dmi_write(sys_vendor="Framework",
+ product_name="Laptop (13th Gen Intel Core)")
+ self.assertTrue(self.trims())
+
+ def test_framework_12_intel_trims(self):
+ self.dmi_write(sys_vendor="Framework",
+ product_name="Laptop 12 (13th Gen Intel Core)")
+ self.assertTrue(self.trims())
+
+ # -------------------------------------------------------- boundary ----
+ def test_ratio_framework_desktop_amd_does_not_trim(self):
+ # The exact strings read off ratio on 2026-07-24. Trimming here would
+ # remove linux-firmware-amdgpu from the machine whose iGPU needs it.
+ self.dmi_write(sys_vendor="Framework",
+ product_name="Desktop (AMD Ryzen AI Max 300 Series)")
+ self.assertFalse(self.trims())
+
+ def test_framework_16_amd_laptop_does_not_trim(self):
+ self.dmi_write(sys_vendor="Framework",
+ product_name="Laptop 16 (AMD Ryzen 7040 Series)")
+ self.assertFalse(self.trims())
+
+ def test_non_framework_intel_laptop_does_not_trim(self):
+ # The firmware set is only known for Framework hardware. The model
+ # deliberately carries both "Laptop" and "Intel", so the vendor check
+ # is the only condition that can reject it -- a fixture that failed the
+ # model checks too would pass no matter what the vendor check did.
+ self.dmi_write(sys_vendor="Dell Inc.",
+ product_name="Laptop 15 (Intel Core i7)")
+ self.assertFalse(self.trims())
+
+ def test_framework_desktop_naming_an_intel_cpu_does_not_trim(self):
+ # No such machine ships today -- the Framework Desktop is AMD -- but
+ # "an Intel Framework desktop will never exist" is exactly the kind of
+ # assumption that made the old gate wrong. The model must say Laptop,
+ # so a desktop is refused on the model rather than on its CPU vendor.
+ self.dmi_write(sys_vendor="Framework",
+ product_name="Desktop (Intel Core Ultra)")
+ self.assertFalse(self.trims())
+
+ def test_vendor_match_is_case_insensitive(self):
+ self.dmi_write(sys_vendor="framework",
+ product_name="Laptop (13th Gen intel Core)")
+ self.assertTrue(self.trims())
+
+ def test_vendor_with_surrounding_whitespace_still_matches(self):
+ # DMI strings are vendor-supplied and often padded.
+ self.dmi_write(sys_vendor=" Framework ",
+ product_name="Laptop (13th Gen Intel Core)")
+ self.assertTrue(self.trims())
+
+ # ----------------------------------------------------------- error ----
+ def test_missing_dmi_directory_does_not_trim(self):
+ self.assertFalse(self.trims(dmi_dir=os.path.join(self.tmp, "nope")))
+
+ def test_missing_product_name_does_not_trim(self):
+ self.dmi_write(sys_vendor="Framework")
+ self.assertFalse(self.trims())
+
+ def test_missing_sys_vendor_does_not_trim(self):
+ self.dmi_write(product_name="Laptop (13th Gen Intel Core)")
+ self.assertFalse(self.trims())
+
+ def test_empty_dmi_values_do_not_trim(self):
+ self.dmi_write(sys_vendor="", product_name="")
+ self.assertFalse(self.trims())
+
+class GpuVendorEvidence(unittest.TestCase):
+ """trim_firmware deletes GPU firmware, so what it needs to know is which
+ GPU is physically present -- and the kernel already answers that.
+
+ The DMI model string cannot. It is a marketing name: it does not know a
+ swapped card, and it cannot see hardware the allowlist has never met. The
+ same file already reads PCI modalias twice (install_gpu_drivers, and
+ nvidia_preflight_report, which parameterises its globs for exactly this
+ kind of test). detect_gpu_vendors is that read, made reusable, so the trim
+ can refuse on evidence rather than on a name.
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="gpu-vendor-test-")
+ self.drm = os.path.join(self.tmp, "drm")
+ self.pci = os.path.join(self.tmp, "pci")
+ os.makedirs(self.drm)
+ os.makedirs(self.pci)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^detect_gpu_vendors() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'detect_gpu_vendors "$DRM_GLOB" "$PCI_GLOB"\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def card(self, root, name, modalias):
+ d = os.path.join(root, name, "device")
+ os.makedirs(d, exist_ok=True)
+ with open(os.path.join(d, "modalias"), "w") as f:
+ f.write(modalias + "\n")
+
+ def vendors(self):
+ env = dict(os.environ)
+ env["DRM_GLOB"] = os.path.join(self.drm, "card*", "device", "modalias")
+ env["PCI_GLOB"] = os.path.join(self.pci, "*", "device", "modalias")
+ r = subprocess.run(["bash", self.wrapper, ARCHSETUP],
+ capture_output=True, text=True, env=env)
+ return sorted(v for v in r.stdout.split() if v)
+
+ # ---------------------------------------------------------- normal ----
+ def test_intel_igpu_detected(self):
+ self.card(self.drm, "card0", "pci:v00008086d00007D55sv00001414bc03sc00i00")
+ self.assertEqual(self.vendors(), ["intel"])
+
+ def test_amd_igpu_detected(self):
+ self.card(self.drm, "card0", "pci:v00001002d0000150Esv00001414bc03sc00i00")
+ self.assertEqual(self.vendors(), ["amd"])
+
+ # -------------------------------------------------------- boundary ----
+ def test_nvidia_detected_in_either_hex_case(self):
+ self.card(self.drm, "card0", "pci:v000010DEd00002684bc03sc00i00")
+ self.assertEqual(self.vendors(), ["nvidia"])
+ shutil.rmtree(os.path.join(self.drm, "card0"))
+ self.card(self.drm, "card0", "pci:v000010ded00002684bc03sc00i00")
+ self.assertEqual(self.vendors(), ["nvidia"])
+
+ def test_hybrid_machine_reports_both(self):
+ self.card(self.drm, "card0", "pci:v00008086d00007D55bc03sc00i00")
+ self.card(self.drm, "card1", "pci:v000010DEd00002684bc03sc00i00")
+ self.assertEqual(self.vendors(), ["intel", "nvidia"])
+
+ def test_falls_back_to_pci_when_drm_is_empty(self):
+ # Early boot / chroot: no DRM nodes yet, same ruling install_gpu_drivers
+ # already makes.
+ self.card(self.pci, "0000:00:02.0", "pci:v00001002d0000150Ebc03sc00i00")
+ self.assertEqual(self.vendors(), ["amd"])
+
+ def test_pci_fallback_ignores_non_display_devices(self):
+ # An NVIDIA audio function on the same card is bc04, not bc03.
+ self.card(self.pci, "0000:01:00.1", "pci:v000010DEd000022BAbc04sc03i00")
+ self.assertEqual(self.vendors(), [])
+
+ # ----------------------------------------------------------- error ----
+ def test_no_gpu_anywhere_reports_nothing(self):
+ self.assertEqual(self.vendors(), [])
+
+ def test_unreadable_modalias_is_skipped_not_fatal(self):
+ d = os.path.join(self.drm, "card0", "device")
+ os.makedirs(d)
+ os.mkdir(os.path.join(d, "modalias")) # a directory, not a file
+ self.assertEqual(self.vendors(), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_mark_volatile_configs.py b/tests/installer-steps/test_mark_volatile_configs.py
new file mode 100644
index 0000000..27eac61
--- /dev/null
+++ b/tests/installer-steps/test_mark_volatile_configs.py
@@ -0,0 +1,216 @@
+"""Tests for mark_volatile_configs, the tail of the dotfiles install.
+
+Four stowed configs get rewritten in place by the apps that own them (btop,
+qalculate, calibre, waypaper). The dotfiles repo handles that with git's
+skip-worktree bit, applied by its own `skip-volatile` make target, which
+`make stow` runs as its last step.
+
+archsetup stows inline rather than through that Makefile, and it never ran the
+step -- so a machine installed by archsetup alone starts with a dotfiles repo
+that goes dirty as soon as those apps run, and every later `git pull --ff-only`
+trips over paths the user never edited. Both daily drivers carry the bits
+today, but archsetup contains no code that sets them: they were applied by a
+hand-run `make stow`.
+
+The fix calls the dotfiles Makefile's target rather than reimplementing the
+logic here, so the volatile list stays in one place. That is what these tests
+pin: that archsetup delegates, and that it degrades quietly when the checkout
+it was handed cannot answer.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against a fixture git repo, with sudo stubbed
+on PATH.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_mark_volatile_configs
+"""
+
+import os
+import shutil
+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 real target, copied from the dotfiles Makefile. The point of the fixture
+# is that archsetup calls *a* skip-volatile target rather than doing the work
+# itself; the body here mirrors the real one so the assertions are meaningful.
+MAKEFILE = textwrap.dedent("""\
+ DOTFILES := $(shell pwd)
+ VOLATILE := $(shell sed 's/#.*//' $(DOTFILES)/volatile-configs 2>/dev/null)
+
+ skip-volatile:
+ \t@for f in $(VOLATILE); do \\
+ \t\tif git -C $(DOTFILES) ls-files --error-unmatch "$$f" >/dev/null 2>&1; then \\
+ \t\t\tgit -C $(DOTFILES) update-index --skip-worktree "$$f" && echo " skip-worktree: $$f"; \\
+ \t\telse \\
+ \t\t\techo " WARN: not tracked, skipping: $$f"; \\
+ \t\tfi; \\
+ \tdone
+""")
+
+# sudo isn't available (and isn't wanted) in a unit test. Drop `-u <user>` and
+# exec the rest, so the function's real command line is what runs.
+SUDO_STUB = textwrap.dedent("""\
+ #!/bin/bash
+ # Record the argv archsetup handed sudo, then drop `-u <user>` and exec the
+ # rest, so the function's real command line is what runs.
+ printf '%s\\n' "$*" >> "$SUDO_CALLS"
+ if [ "$1" = "-u" ]; then shift 2; fi
+ exec "$@"
+""")
+
+
+class MarkVolatileConfigsHarness(unittest.TestCase):
+ """Source mark_volatile_configs out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="volatile-test-")
+ self.repo = os.path.join(self.tmp, "dotfiles")
+ os.mkdir(self.repo)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ sudo = os.path.join(self.bindir, "sudo")
+ with open(sudo, "w") as f:
+ f.write(SUDO_STUB)
+ os.chmod(sudo, 0o755)
+ self.logfile = os.path.join(self.tmp, "install.log")
+ self.sudo_calls = os.path.join(self.tmp, "sudo-calls")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^mark_volatile_configs() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'display() { echo "DISPLAY:$2"; }\n'
+ 'error_warn() { echo "WARN:$1"; return 1; }\n'
+ 'mark_volatile_configs "$REPO" testuser\n'
+ 'echo "RETURNED=$?"\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def git(self, *args):
+ return subprocess.run(["git", "-C", self.repo, *args],
+ capture_output=True, text=True)
+
+ def make_repo(self, tracked=("common/.config/btop/btop.conf",),
+ volatile=None, makefile: "str | None" = MAKEFILE):
+ self.git("init", "-q")
+ self.git("config", "user.email", "t@example.com")
+ self.git("config", "user.name", "t")
+ for rel in tracked:
+ path = os.path.join(self.repo, rel)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as f:
+ f.write("original\n")
+ listed = tracked if volatile is None else volatile
+ with open(os.path.join(self.repo, "volatile-configs"), "w") as f:
+ f.write("# apps rewrite these in place\n")
+ f.write("".join(rel + "\n" for rel in listed))
+ if makefile is not None:
+ with open(os.path.join(self.repo, "Makefile"), "w") as f:
+ f.write(makefile)
+ self.git("add", "-A")
+ self.git("commit", "-q", "-m", "fixture")
+
+ def run_step(self):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ env["REPO"] = self.repo
+ env["logfile"] = self.logfile
+ env["SUDO_CALLS"] = self.sudo_calls
+ return subprocess.run(["bash", self.wrapper, ARCHSETUP],
+ capture_output=True, text=True, env=env)
+
+ def sudo_argv(self):
+ """The argument lines sudo was called with, one per invocation."""
+ if not os.path.exists(self.sudo_calls):
+ return []
+ with open(self.sudo_calls) as f:
+ return [line.rstrip("\n") for line in f if line.strip()]
+
+ def skipped(self):
+ """Paths carrying the skip-worktree bit."""
+ r = self.git("ls-files", "-v")
+ return sorted(line[2:] for line in r.stdout.splitlines()
+ if line.startswith("S "))
+
+ # ---------------------------------------------------------- normal ----
+ def test_sets_skip_worktree_on_every_volatile_path(self):
+ paths = ["common/.config/btop/btop.conf",
+ "hyprland/.config/waypaper/config.ini"]
+ self.make_repo(tracked=paths)
+ self.assertEqual(self.skipped(), []) # the bit is not set by default
+ r = self.run_step()
+ self.assertIn("RETURNED=0", r.stdout,
+ "stdout=%r stderr=%r" % (r.stdout, r.stderr))
+ self.assertEqual(self.skipped(), sorted(paths))
+
+ def test_runs_make_as_the_user_not_as_root(self):
+ # archsetup runs as root against a user-owned checkout. Letting root
+ # write .git/index leaves it root-owned, and the user's next git
+ # command then cannot update the index at all.
+ self.make_repo()
+ self.run_step()
+ self.assertEqual(self.sudo_argv(),
+ ["-u testuser make -C %s skip-volatile" % self.repo])
+
+ def test_leaves_non_volatile_tracked_files_alone(self):
+ self.make_repo(tracked=["common/.config/btop/btop.conf",
+ "common/.bashrc"],
+ volatile=["common/.config/btop/btop.conf"])
+ self.run_step()
+ self.assertEqual(self.skipped(), ["common/.config/btop/btop.conf"])
+
+ # -------------------------------------------------------- boundary ----
+ def test_no_makefile_is_a_quiet_no_op(self):
+ # DOTFILES_DIR can point at a checkout that predates the target, or at
+ # a fork. Nothing to delegate to is not an error.
+ self.make_repo(makefile=None)
+ r = self.run_step()
+ self.assertIn("RETURNED=0", r.stdout)
+ self.assertNotIn("WARN:", r.stdout)
+ self.assertEqual(self.skipped(), [])
+
+ def test_empty_volatile_list_sets_nothing_and_does_not_warn(self):
+ self.make_repo(tracked=["common/.config/btop/btop.conf"], volatile=[])
+ r = self.run_step()
+ self.assertIn("RETURNED=0", r.stdout)
+ self.assertNotIn("WARN:", r.stdout)
+ self.assertEqual(self.skipped(), [])
+
+ def test_untracked_path_in_the_list_does_not_fail_the_step(self):
+ # The Makefile warns and carries on; a stale entry must not take the
+ # install down, and the tracked entries still get their bit.
+ self.make_repo(tracked=["common/.config/btop/btop.conf"],
+ volatile=["common/.config/btop/btop.conf",
+ "common/.config/gone/removed.conf"])
+ r = self.run_step()
+ self.assertIn("RETURNED=0", r.stdout)
+ self.assertEqual(self.skipped(), ["common/.config/btop/btop.conf"])
+
+ # ----------------------------------------------------------- error ----
+ def test_makefile_without_the_target_warns_and_returns(self):
+ self.make_repo(makefile="all:\n\t@true\n")
+ r = self.run_step()
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(self.skipped(), [])
+
+ def test_make_output_goes_to_the_logfile_not_the_console(self):
+ self.make_repo(tracked=["common/.config/btop/btop.conf"])
+ r = self.run_step()
+ self.assertNotIn("skip-worktree:", r.stdout)
+ with open(self.logfile) as f:
+ self.assertIn("skip-worktree:", f.read())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index 2a771ba..8de75cc 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -147,3 +147,61 @@ class MaintenanceConfigDispatch(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+# Helper -> the function that must call it. Every one of these helpers has its
+# own suite proving it behaves correctly; none of those suites proves it is
+# wired in. Deleting the call left every test green in five separate suites,
+# including the one guarding a `pacman -Rdd` of twelve firmware packages.
+#
+# This is a static check on purpose. The behavioural harness above stubs only
+# the names it is given and runs the rest of the body for real, which is safe
+# for an orchestrator whose body is nothing but calls, and emphatically not
+# safe for a leaf like trim_firmware. Cheaper and machine-independent too: the
+# alternative depends on what /proc/cpuinfo says on the box running the tests.
+CALL_SITES = {
+ "trim_firmware": ["detect_gpu_vendors", "is_framework_intel_laptop"],
+ "finalize_dotfiles": ["mark_volatile_configs"],
+ "preflight_checks": ["select_locale", "nvidia_preflight", "check_disk_space"],
+ "configure_encrypted_autologin": ["configure_autologin"],
+ "configure_initramfs_hook": ["switch_udev_hook_to_systemd"],
+ "add_nvme_early_module": ["ensure_nvme_early_module"],
+ "configure_snapshots": ["configure_zfs_snapshots", "configure_btrfs_snapshots"],
+ "configure_zfs_snapshots": ["zfs_scrub_timer_units"],
+ "install_maintenance_config": ["install_maintenance_thresholds"],
+}
+
+
+def function_body(name):
+ """The body of `name` from archsetup, comments and blank lines stripped, so
+ a match is a real invocation rather than a mention in a comment."""
+ with open(ARCHSETUP) as f:
+ src = f.read()
+ start = src.index(f"\n{name}() {{\n")
+ end = src.index("\n}\n", start)
+ lines = src[start:end].splitlines()[1:]
+ keep = []
+ for line in lines:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ keep.append(line.split(" #", 1)[0])
+ return "\n".join(keep)
+
+
+class CallSiteWiring(unittest.TestCase):
+ def test_every_guarded_helper_is_actually_called(self):
+ for caller, callees in CALL_SITES.items():
+ body = function_body(caller)
+ for callee in callees:
+ with self.subTest(caller=caller, callee=callee):
+ self.assertRegex(
+ body, rf"(^|[\s;&|(]){callee}\b",
+ f"{caller} no longer calls {callee}; its own suite "
+ f"cannot see this")
+
+ def test_the_check_would_notice_a_missing_call(self):
+ # The check is only worth having if it fails when the call goes away.
+ body = function_body("trim_firmware").replace("detect_gpu_vendors", "")
+ with self.assertRaises(AssertionError):
+ self.assertRegex(body, r"(^|[\s;&|(])detect_gpu_vendors\b")
diff --git a/tests/installer-steps/test_select_locale.py b/tests/installer-steps/test_select_locale.py
new file mode 100644
index 0000000..b9b9ce7
--- /dev/null
+++ b/tests/installer-steps/test_select_locale.py
@@ -0,0 +1,160 @@
+"""Tests for select_locale in the archsetup installer.
+
+select_locale resolves the system locale during pre-flight. Three inputs, in
+precedence order: an existing LANG= in locale.conf wins outright, then LOCALE
+from the config file, then a numbered menu.
+
+That menu is why these tests exist. It carries its own "[1]" default, LOCALE is
+optional in archsetup.conf.example, and archsetup does not require an archangel
+install -- so an unattended run (--config-file) can reach the menu's bare read
+with nobody there to answer it. configure_build_environment's own "no LANG= in
+locale.conf" branch shows archsetup expects that state to occur. Unattended
+therefore takes the menu's default, the same ruling nvidia_preflight and
+configure_autologin make for their prompts.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against a fixture locale.conf.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_select_locale
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+class SelectLocaleHarness(unittest.TestCase):
+ """Source select_locale out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="select-locale-test-")
+ self.conf = os.path.join(self.tmp, "locale.conf")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^select_locale() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'select_locale "$LOCALE_CONF"\n'
+ 'echo "RESOLVED=$locale"\n'
+ # Whatever the prompt did not consume is still on stdin.
+ 'if IFS= read -r leftover; then echo "LEFTOVER:$leftover"; '
+ 'else echo "LEFTOVER:"; fi\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def write_conf(self, body):
+ with open(self.conf, "w") as f:
+ f.write(body)
+
+ def run_select(self, locale="", config_file="", stdin=""):
+ env = dict(os.environ)
+ env["locale"] = locale
+ env["config_file"] = config_file
+ env["LOCALE_CONF"] = self.conf
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP],
+ input=stdin, capture_output=True, text=True, env=env,
+ )
+
+ def resolved(self, r):
+ for line in r.stdout.splitlines():
+ if line.startswith("RESOLVED="):
+ return line.split("=", 1)[1]
+ self.fail("no RESOLVED line; stdout=%r stderr=%r" % (r.stdout, r.stderr))
+
+ # ---------------------------------------------------------- normal ----
+ def test_existing_locale_conf_wins_and_does_not_prompt(self):
+ self.write_conf("LANG=de_DE.UTF-8\n")
+ r = self.run_select(stdin="sentinel\n")
+ self.assertIn("[OK] Locale: de_DE.UTF-8", r.stdout)
+ # locale.conf is authoritative, so the global is deliberately left
+ # unset -- configure_build_environment reads LANG out of the file.
+ self.assertEqual(self.resolved(r), "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_config_file_locale_used_when_conf_has_no_lang(self):
+ self.write_conf("")
+ r = self.run_select(locale="fr_FR.UTF-8", stdin="sentinel\n")
+ self.assertIn("(from config)", r.stdout)
+ self.assertEqual(self.resolved(r), "fr_FR.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_menu_choice_selects_that_locale(self):
+ self.write_conf("")
+ r = self.run_select(stdin="3\nsentinel\n")
+ self.assertEqual(self.resolved(r), "de_DE.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # -------------------------------------------------------- boundary ----
+ def test_missing_locale_conf_falls_through_to_the_menu(self):
+ # No file at all, not just no LANG= line.
+ r = self.run_select(stdin="2\nsentinel\n")
+ self.assertEqual(self.resolved(r), "en_GB.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_bare_enter_takes_choice_one(self):
+ self.write_conf("")
+ r = self.run_select(stdin="\nsentinel\n")
+ self.assertEqual(self.resolved(r), "en_US.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_config_file_takes_the_default_without_reading(self):
+ # THE BUG. --config-file is the documented unattended mode and LOCALE
+ # is optional, so this is an ordinary unattended install on a machine
+ # whose locale.conf carries no LANG=.
+ self.write_conf("")
+ r = self.run_select(config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertEqual(self.resolved(r), "en_US.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_still_prefers_an_existing_locale_conf(self):
+ self.write_conf("LANG=ja_JP.UTF-8\n")
+ r = self.run_select(config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertIn("[OK] Locale: ja_JP.UTF-8", r.stdout)
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_still_prefers_the_configured_locale(self):
+ # Unattended does not mean "always en_US" -- LOCALE is an answer, and
+ # the menu default must not override it.
+ self.write_conf("")
+ r = self.run_select(locale="pt_BR.UTF-8",
+ config_file="/etc/archsetup.conf", stdin="sentinel\n")
+ self.assertEqual(self.resolved(r), "pt_BR.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # ----------------------------------------------------------- error ----
+ def test_unrecognised_choice_falls_back_to_en_us(self):
+ self.write_conf("")
+ r = self.run_select(stdin="42\nsentinel\n")
+ self.assertEqual(self.resolved(r), "en_US.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_manual_entry_reads_a_second_line(self):
+ self.write_conf("")
+ r = self.run_select(stdin="9\nnl_NL.UTF-8\nsentinel\n")
+ self.assertEqual(self.resolved(r), "nl_NL.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_manual_entry_left_empty_falls_back_to_en_us(self):
+ self.write_conf("")
+ r = self.run_select(stdin="9\n\nsentinel\n")
+ self.assertEqual(self.resolved(r), "en_US.UTF-8")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
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..54969e6
--- /dev/null
+++ b/tests/installer-steps/test_switch_udev_hook.py
@@ -0,0 +1,178 @@
+"""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_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()