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
|
"""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()
|