- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
162 lines
6.0 KiB
Python
162 lines
6.0 KiB
Python
"""Tests for cli.py — the two signal paths and what authorizes them.
|
|
|
|
Run: python3 -m unittest discover -s test
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from support import REPO # noqa: E402
|
|
|
|
|
|
class RetireAuthorization(unittest.TestCase):
|
|
"""The guard that stands between a version mismatch and a SIGTERM.
|
|
|
|
It used to be a shell one-liner that could not be tested; it passed a
|
|
NUL to `tr`, so execve truncated the script and no daemon was ever
|
|
actually retired. These cases pin each fact it checks.
|
|
"""
|
|
|
|
def setUp(self):
|
|
from nexthopd.cli import authorized_to_retire
|
|
self.auth = authorized_to_retire
|
|
from nexthopd.daemon import proc_start_ticks
|
|
self.ticks = proc_start_ticks
|
|
|
|
def spawn(self, args, cwd=None):
|
|
import subprocess
|
|
p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL)
|
|
self.addCleanup(lambda: (p.kill(), p.wait()))
|
|
time.sleep(0.4)
|
|
return p
|
|
|
|
def daemon_shaped(self):
|
|
"""A process whose argv is exactly `python -m nexthopd`.
|
|
|
|
A stand-in rather than the real daemon: the real one would lose the
|
|
flock race against whatever is already running and exit before the
|
|
guard could look at it, and a test has no business probing the
|
|
network. The guard reads argv, owner and start time — all of which
|
|
this reproduces exactly.
|
|
"""
|
|
d = tempfile.mkdtemp()
|
|
self.addCleanup(lambda: __import__("shutil").rmtree(d, True))
|
|
pkg = Path(d) / "nexthopd"
|
|
pkg.mkdir()
|
|
(pkg / "__init__.py").write_text("")
|
|
(pkg / "__main__.py").write_text("import time\ntime.sleep(30)\n")
|
|
return self.spawn([sys.executable, "-m", "nexthopd"], cwd=d)
|
|
|
|
def test_daemon_argv_with_matching_start_is_authorized(self):
|
|
p = self.daemon_shaped()
|
|
self.assertTrue(self.auth(p.pid, self.ticks(p.pid)))
|
|
|
|
def test_wrong_start_time_refused(self):
|
|
p = self.daemon_shaped()
|
|
self.assertFalse(self.auth(p.pid, self.ticks(p.pid) + 1))
|
|
|
|
def test_zero_start_skips_only_the_time_check(self):
|
|
# live.json from a daemon too old to publish pid_start: argv and
|
|
# ownership still have to hold.
|
|
p = self.daemon_shaped()
|
|
self.assertTrue(self.auth(p.pid, 0))
|
|
|
|
def test_other_python_process_refused(self):
|
|
# Same interpreter, different module: never ours to signal.
|
|
p = self.spawn([sys.executable, "-c", "import time; time.sleep(30)"])
|
|
self.assertFalse(self.auth(p.pid, 0))
|
|
|
|
def test_lookalike_argv_refused(self):
|
|
# A process that merely mentions nexthopd is not the daemon.
|
|
p = self.spawn([sys.executable, "-c",
|
|
"import time; time.sleep(30) # -m nexthopd"])
|
|
self.assertFalse(self.auth(p.pid, 0))
|
|
|
|
def test_extra_arguments_refused(self):
|
|
p = self.spawn([sys.executable, "-m", "nexthopd.cli", "stream", "live"])
|
|
self.assertFalse(self.auth(p.pid, 0))
|
|
|
|
def test_pid_that_does_not_exist_refused(self):
|
|
self.assertFalse(self.auth(999999, 0))
|
|
|
|
def test_pid_one_refused(self):
|
|
# Owned by root, so the ownership check alone stops us.
|
|
self.assertFalse(self.auth(1, 0))
|
|
|
|
def test_command_string_carries_no_nul(self):
|
|
# The regression itself: the argv QML hands to sh must survive
|
|
# execve, which a NUL byte would truncate.
|
|
import subprocess
|
|
cmd = ('cd "$1" && exec python3 -m nexthopd.cli retire '
|
|
'--pid "$2" --start "$3"')
|
|
self.assertNotIn("\0", cmd)
|
|
r = subprocess.run(["sh", "-c", cmd, "sh", str(REPO), "999999", "0"],
|
|
capture_output=True)
|
|
self.assertEqual(r.returncode, 1) # refused, not a syntax error
|
|
self.assertEqual(r.stderr, b"")
|
|
|
|
|
|
class PeakSignalAuthorization(unittest.TestCase):
|
|
"""`nexthop peak` signals only a verified lock holder. SIGUSR1's default
|
|
disposition is terminate, so the wrong pid is not a harmless miss."""
|
|
|
|
def setUp(self):
|
|
import contextlib, io
|
|
from nexthopd import cli, paths
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
os.environ["XDG_STATE_HOME"] = self.dir.name
|
|
self.cli = cli
|
|
self.lock = str(paths.lock_path())
|
|
os.makedirs(os.path.dirname(self.lock), mode=0o700, exist_ok=True)
|
|
self.killed = []
|
|
self._kill = os.kill
|
|
os.kill = lambda pid, sig: self.killed.append((pid, sig))
|
|
self._quiet = contextlib.redirect_stdout(io.StringIO())
|
|
self._quiet.__enter__()
|
|
|
|
def tearDown(self):
|
|
self._quiet.__exit__(None, None, None)
|
|
os.kill = self._kill
|
|
del os.environ["XDG_STATE_HOME"]
|
|
self.dir.cleanup()
|
|
|
|
def test_a_lock_nobody_holds_is_not_a_daemon(self):
|
|
# A dead daemon's pid, recycled by a live process — this one.
|
|
Path(self.lock).write_text(str(os.getpid()))
|
|
self.assertEqual(self.cli.cmd_peak(None), 1)
|
|
self.assertEqual(self.killed, [])
|
|
|
|
def test_a_held_lock_still_needs_the_holders_identity(self):
|
|
import fcntl
|
|
# Hold the lock ourselves: our argv is the test runner, not
|
|
# `python -m nexthopd`, so the identity check must refuse it.
|
|
fd = os.open(self.lock, os.O_RDWR | os.O_CREAT, 0o600)
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
os.write(fd, str(os.getpid()).encode())
|
|
try:
|
|
self.assertEqual(self.cli.cmd_peak(None), 1)
|
|
self.assertEqual(self.killed, [])
|
|
finally:
|
|
os.close(fd)
|
|
|
|
def test_a_symlink_at_the_lock_path_is_refused(self):
|
|
os.symlink("/proc/self/stat", self.lock)
|
|
self.assertEqual(self.cli.cmd_peak(None), 1)
|
|
self.assertEqual(self.killed, [])
|
|
|
|
def test_missing_lock_file_is_not_a_daemon(self):
|
|
self.assertEqual(self.cli.cmd_peak(None), 1)
|
|
self.assertEqual(self.killed, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|