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
|
"""Tests for the hypr-live-update-guard pacman PreTransaction hook script.
The guard aborts a live pacman upgrade of GPU/compositor runtime libraries
(mesa, hyprland, wayland, GPU drivers) while a Hyprland session is running,
so the compositor doesn't SIGABRT when a now-"(deleted)" library is next
called. It reads the triggering package names on stdin (pacman NeedsTargets)
and exits non-zero to abort the transaction (AbortOnFail) before any package
is swapped. When Hyprland isn't running, or an override is set, it exits 0
and the upgrade proceeds.
Test seams (env vars the production script honors):
HYPR_GUARD_RUNNING 1/0 forces the Hyprland-running check (default: pgrep)
HYPR_ALLOW_LIVE_UPDATE 1 overrides the guard (proceed anyway)
HYPR_GUARD_SENTINEL path whose existence also overrides the guard
HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the
pacman -Q / expac -S version lookups; when set,
a package absent from the map reads as unknown
(conservative block)
Run from repo root:
python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard
"""
import os
import subprocess
import tempfile
import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
GUARD = os.path.join(REPO_ROOT, "scripts", "hypr-live-update-guard")
# Every package the legacy tests feed, mapped to a version CHANGE — those
# tests describe real upgrades, and the map keeps them hermetic (no
# pacman/expac calls against the test host).
CHANGING_VERSIONS = "\n".join((
"mesa 25.1.0-1 26.0.0-1",
"hyprland 0.55.3-1 0.55.4-1",
"vulkan-radeon 25.1.0-1 26.0.0-1",
))
def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None,
versions=CHANGING_VERSIONS):
env = dict(os.environ)
env["HYPR_GUARD_RUNNING"] = running
if allow is not None:
env["HYPR_ALLOW_LIVE_UPDATE"] = allow
# Point the sentinel at a path that does not exist unless a test sets one,
# so the host's real /run state can't leak into the result.
env["HYPR_GUARD_SENTINEL"] = sentinel if sentinel else "/nonexistent/guard-sentinel"
env["HYPR_GUARD_VERSIONS"] = versions
return subprocess.run(
["sh", GUARD],
input=stdin, capture_output=True, text=True, timeout=10, env=env,
)
class HyprLiveUpdateGuard(unittest.TestCase):
# --- Normal cases ---------------------------------------------------
def test_running_with_dangerous_pkg_aborts(self):
r = run_guard(stdin="mesa\n", running="1")
self.assertEqual(r.returncode, 1, r.stderr)
def test_abort_message_names_the_package_and_tty_remedy(self):
r = run_guard(stdin="mesa\n", running="1")
self.assertIn("mesa", r.stderr)
self.assertIn("TTY", r.stderr)
def test_not_running_allows(self):
r = run_guard(stdin="mesa\n", running="0")
self.assertEqual(r.returncode, 0, r.stderr)
def test_not_running_is_silent(self):
r = run_guard(stdin="mesa\nhyprland\n", running="0")
self.assertEqual(r.stderr.strip(), "")
# --- Boundary cases -------------------------------------------------
def test_multiple_packages_all_listed(self):
r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1")
self.assertEqual(r.returncode, 1)
for pkg in ("mesa", "hyprland", "vulkan-radeon"):
self.assertIn(pkg, r.stderr)
def test_running_with_empty_stdin_still_guards(self):
# The hook only fires when dangerous targets exist, so an empty target
# list shouldn't normally happen; if Hyprland is up, stay safe (abort).
r = run_guard(stdin="", running="1")
self.assertEqual(r.returncode, 1)
# --- Version awareness ------------------------------------------------
def test_same_version_reinstall_allows(self):
# a pure reinstall replaces identical bytes with identical bytes —
# no live-swap hazard, the guard must let it through
r = run_guard(stdin="hyprland\n", running="1",
versions="hyprland 0.55.4-1 0.55.4-1")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(r.stderr.strip(), "")
def test_all_same_version_multi_pkg_allows(self):
versions = "\n".join(("mesa 26.1.4-1 26.1.4-1",
"hyprland 0.55.4-1 0.55.4-1",
"vulkan-radeon 26.1.4-1 26.1.4-1"))
r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1",
versions=versions)
self.assertEqual(r.returncode, 0, r.stderr)
def test_mixed_blocks_naming_only_the_version_changing(self):
versions = "\n".join(("mesa 25.1.0-1 26.0.0-1",
"hyprland 0.55.4-1 0.55.4-1"))
r = run_guard(stdin="mesa\nhyprland\n", running="1",
versions=versions)
self.assertEqual(r.returncode, 1)
self.assertIn("- mesa", r.stderr)
# the prose mentions the Hyprland session, so assert on the
# package-list line format, not the bare word
self.assertNotIn("- hyprland", r.stderr)
def test_unknown_versions_block_conservatively(self):
# seam set but package absent from the map = the lookup failed
# (AUR target, -U transaction, expac missing) — stay safe
r = run_guard(stdin="mesa\n", running="1", versions="")
self.assertEqual(r.returncode, 1)
self.assertIn("- mesa", r.stderr)
# --- Override / error cases -----------------------------------------
def test_env_override_proceeds_even_when_running(self):
r = run_guard(stdin="mesa\n", running="1", allow="1")
self.assertEqual(r.returncode, 0, r.stderr)
def test_sentinel_file_override_proceeds(self):
with tempfile.NamedTemporaryFile(prefix="guard-allow-") as f:
r = run_guard(stdin="mesa\n", running="1", sentinel=f.name)
self.assertEqual(r.returncode, 0, r.stderr)
def test_sentinel_is_consumed_on_use(self):
# one touch = one transaction: if the override's cleanup never runs
# (a crashed caller), a leftover sentinel must not keep the guard
# disarmed until reboot — the hook deletes it as it honors it
fd, path = tempfile.mkstemp(prefix="guard-allow-")
os.close(fd)
try:
r = run_guard(stdin="mesa\n", running="1", sentinel=path)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertFalse(os.path.exists(path))
finally:
if os.path.exists(path):
os.unlink(path)
def test_env_override_consumes_nothing(self):
# the env override isn't a file; nothing to consume, still proceeds
r = run_guard(stdin="mesa\n", running="1", allow="1")
self.assertEqual(r.returncode, 0, r.stderr)
def test_override_env_zero_does_not_bypass(self):
r = run_guard(stdin="mesa\n", running="1", allow="0")
self.assertEqual(r.returncode, 1, r.stderr)
if __name__ == "__main__":
unittest.main()
|