diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-13 22:34:42 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-13 22:34:42 -0500 |
| commit | a42e06d6ef4fc6296a17c69580aa9911667a6c88 (patch) | |
| tree | 74475a705d891f8f0347e11b877812ffe0f85377 /tests | |
| parent | ab44b307f67159915fafa88b09b81a37b59b8e23 (diff) | |
| download | archsetup-a42e06d6ef4fc6296a17c69580aa9911667a6c88.tar.gz archsetup-a42e06d6ef4fc6296a17c69580aa9911667a6c88.zip | |
feat(installer): grant brightness control to the video group
Arch's brightnessctl ships no udev rules and leans on logind, which grants brightness writes only to the active seat session. Anything outside that session (a script, an ssh shell, a panel launched into a different one) meets root-owned sysfs and gets EPERM. On velox both panel sliders were dead after a fresh install.
configure_backlight_access writes a udev rule making the backlight and keyboard-LED brightness attributes group-writable by video, which create_user already adds the user to. I granted the keyboard LED to video rather than input: anyone in input can read every input device, which is too much authority for dimming a keyboard.
essential_services is already marked complete on machines installed before this step, so a re-run skips them. The post-install checklist carries the manual route.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/installer-steps/test_configure_backlight_access.py | 139 | ||||
| -rw-r--r-- | tests/installer-steps/test_orchestrators.py | 1 |
2 files changed, 140 insertions, 0 deletions
diff --git a/tests/installer-steps/test_configure_backlight_access.py b/tests/installer-steps/test_configure_backlight_access.py new file mode 100644 index 0000000..6fd171a --- /dev/null +++ b/tests/installer-steps/test_configure_backlight_access.py @@ -0,0 +1,139 @@ +"""Test configure_backlight_access — udev rule for brightness writes. + +Arch's brightnessctl ships no udev rules: it relies on logind, which grants +brightness writes only to the *active seat session*. Anything outside that +session — a script, a remote shell, a panel launched into a different +session — gets EPERM against root-owned sysfs. Found on velox 2026-08-13: +on a fresh install the desktop settings panel's screen and keyboard +brightness sliders were both inert. + +The step drops a rule making the brightness attributes group-writable by +video (the group create_user already adds the user to). leds are granted to +video as well rather than input, so nobody needs input-group membership — +and the keylogging surface that carries — just to dim a keyboard. + +Method: sed-extract configure_backlight_access from the real `archsetup`, +point it at a temp rules dir, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_backlight_access +""" + +import os +import re +import stat +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") + + +def run(ruledir, marker=None): + # The step sends udevadm's own output to $logfile, so the stub records + # its argv to a marker file instead of stdout — writing straight to the + # file is unaffected by the caller's redirect. + marker = marker or os.devnull + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + udevadm() {{ echo "UDEVADM: $*" >> "{marker}"; }} + source <(sed -n '/^configure_backlight_access() {{/,/^}}/p' "{ARCHSETUP}") + configure_backlight_access "{ruledir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + 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 ConfigureBacklightAccess(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_rule_covering_backlight_and_keyboard_leds(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + rule = os.path.join(d, "90-backlight.rules") + self.assertTrue(os.path.exists(rule)) + body = open(rule).read() + self.assertIn('SUBSYSTEM=="backlight"', body) + self.assertIn('SUBSYSTEM=="leds"', body) + self.assertIn('KERNEL=="*kbd_backlight"', body) + + def test_grants_the_video_group_write_access(self): + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertIn("chgrp video", body) + self.assertIn("chmod g+w", body) + + def test_does_not_use_the_input_group(self): + # Granting leds to input would require input-group membership, + # which also confers read access to every input device. + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertNotIn("chgrp input", body) + + def test_rule_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(os.path.join(d, "90-backlight.rules")).st_mode) + self.assertEqual(mode, 0o644) + + def test_reloads_udev_so_the_rule_applies_without_a_reboot(self): + with tempfile.TemporaryDirectory() as d: + marker = os.path.join(d, "udevadm.calls") + run(d, marker=marker) + calls = open(marker).read() + self.assertIn("UDEVADM: control --reload", calls) + self.assertRegex(calls, r"UDEVADM: trigger .*backlight") + self.assertRegex(calls, r"UDEVADM: trigger .*leds") + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_correct_rule(self): + # Compare whole bodies rather than counting lines: a count encodes + # today's line total, so it breaks on a correct edit and passes an + # append regression that happens to hit the same number. + with tempfile.TemporaryDirectory() as d: + run(d) + first = open(os.path.join(d, "90-backlight.rules")).read() + r = run(d) + self.assertEqual(rc_of(r), 0) + second = open(os.path.join(d, "90-backlight.rules")).read() + self.assertEqual(first, second) + + def test_absent_rules_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "udev", "rules.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertTrue(os.path.exists(os.path.join(nested, "90-backlight.rules"))) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + ruledir = os.path.join(d, "ro") + os.mkdir(ruledir) + os.chmod(ruledir, 0o500) + try: + r = run(ruledir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(ruledir, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index 395ec61..34c46d1 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -28,6 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") ORCHESTRATORS = { "essential_services": [ "configure_randomness", "configure_networking", "configure_power", + "configure_backlight_access", "configure_ssh_server", "configure_fail2ban", "configure_firewall", "configure_service_discovery", "configure_job_scheduling", "configure_package_cache", "configure_snapshots", |
