From 3becfac66dc569e71d0f6085fb56e92cfa6d626a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 24 Jul 2026 12:19:04 -0500 Subject: 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. --- tests/installer-steps/test_configure_autologin.py | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/installer-steps/test_configure_autologin.py (limited to 'tests/installer-steps/test_configure_autologin.py') 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() -- cgit v1.2.3