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