aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_switch_udev_hook.py
blob: 54969e66ed8056b31fbdec193cea14a0c4ce93da (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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()