Files
framebridge/source/expanded/framebridge_expanded/uninstall.py
T

147 lines
6.5 KiB
Python

"""Stop only this package's managed user services before Debian removal."""
from pathlib import Path
import hashlib
import os
import pwd
import re
import shlex
import stat
import subprocess
import sys
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
def managed_unit(path, uid):
"""Inspect a bounded owned regular file; never execute user configuration."""
if not UNIT_NAME.fullmatch(path.name):
return False
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != uid or info.st_size > 16384:
return False
raw = os.read(fd, 16385)
finally:
os.close(fd)
lines = raw.decode("utf-8").splitlines()
if not lines or lines[0] != "# FrameBridge managed autostart v1":
return False
if lines.count('Environment="PYTHONPATH=' + MODULE_ROOT + '"') != 1:
return False
starts = [line[10:] for line in lines if line.startswith("ExecStart=")]
if len(starts) != 1:
return False
args = shlex.split(starts[0])
if len(args) != 7 or args[0] not in {"/usr/bin/python3", "/usr/bin/python3.13"}:
return False
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
return False
state = args[6].replace("%%", "%").replace("$$", "$")
if not state.startswith("/") or any(ord(c) < 32 for c in state):
return False
expected = "framebridge-autostart-" + hashlib.sha256(state.encode()).hexdigest()[:16] + ".service"
return path.name == expected
except (OSError, UnicodeError, ValueError):
return False
def cleanup_user(home=None, *, run=None, bus_available=None):
run = run or subprocess.run
home = Path(home) if home is not None else Path.home()
uid = os.getuid()
directory = home / ".config/systemd/user"
if not directory.is_dir():
return 0
units = sorted(p.name for p in directory.glob("framebridge-autostart-*.service") if managed_unit(p, uid))
if not units:
return 0
runtime = Path("/run/user") / str(uid)
if bus_available is None:
try:
bus_available = stat.S_ISSOCK((runtime / "bus").stat().st_mode)
except OSError:
bus_available = False
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "XDG_CONFIG_HOME": str(home / ".config"), "XDG_RUNTIME_DIR": str(runtime), "DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(runtime / "bus")}
failure = False
if bus_available:
try:
result = run(["/usr/bin/systemctl", "--user", "stop", *units], env=env, capture_output=True, timeout=35)
if result.returncode:
current = run(["/usr/bin/systemctl", "--user", "show", *units, "--property=ActiveState", "--value"], env=env, capture_output=True, timeout=5)
values = current.stdout.decode().split() if isinstance(current.stdout, bytes) else current.stdout.split()
failure = current.returncode != 0 or len(values) != len(units) or any(value not in {"inactive", "failed"} for value in values)
except (OSError, subprocess.TimeoutExpired):
failure = True
try:
result = run(["/usr/bin/systemctl", "--user", "--no-reload", "disable", *units], env=env, capture_output=True, timeout=10)
failure = result.returncode != 0 or failure
except (OSError, subprocess.TimeoutExpired):
failure = True
# Remove only enablement symlinks targeting these exact managed files.
# This also works offline: systemctl can delegate file operations to a live
# user manager with a different environment, and no bus exists at all for
# an offline owner. Never delete profile data, ciphertext, or unit files.
for group in directory.iterdir():
if not group.name.endswith((".wants", ".requires")) or group.is_symlink() or not group.is_dir():
continue
for name in units:
link = group / name
if link.is_symlink():
try:
if link.resolve() == (directory / name).resolve():
link.unlink()
except OSError:
failure = True
if bus_available:
try:
run(["/usr/bin/systemctl", "--user", "daemon-reload"], env=env, capture_output=True, timeout=3)
except (OSError, subprocess.TimeoutExpired):
pass # Units are stopped/disabled; an owner may have just logged out.
if failure:
raise RuntimeError("A managed FrameBridge service could not be stopped or disabled.")
return len(units)
def cleanup_all_users(*, run=None, accounts=None):
if os.getuid() != 0:
raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
run = run or subprocess.run
accounts = pwd.getpwall() if accounts is None else accounts
seen = set()
failed = False
for account in accounts:
identity = (account.pw_uid, account.pw_dir)
if identity in seen or account.pw_uid == 65534 or not account.pw_dir.startswith("/") or account.pw_dir in {"/", "/nonexistent", "/dev/null"}:
continue
seen.add(identity)
command = ["/usr/sbin/runuser", "-u", account.pw_name, "--", "/usr/bin/env", "-i", "HOME=" + account.pw_dir, "USER=" + account.pw_name, "LOGNAME=" + account.pw_name, "PATH=/usr/bin:/bin", "LANG=C.UTF-8", "/usr/bin/python3", "-I", "-S", str(Path(__file__).resolve()), "--user"]
try:
result = run(command, capture_output=True, timeout=55)
failed = result.returncode != 0 or failed
except (OSError, subprocess.TimeoutExpired):
failed = True
if failed:
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
try:
if args == ["--all-users"]:
cleanup_all_users()
elif args == ["--user"]:
cleanup_user()
else:
return 2
return 0
except (OSError, RuntimeError, subprocess.TimeoutExpired):
print("FrameBridge service cleanup did not complete. Package data was retained.", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())