#!/usr/bin/env python3 """Narrow post-run cleanup of adopted device helpers in the one disposable prefix. Library only: importing or executing this file sends no signals. Call only while holding .erri-workbench.lock, after normal wineserver/Gamescope shutdown. This is not a general Wine cleanup tool. Environment values are never returned/logged. """ from dataclasses import dataclass from pathlib import Path import os import posixpath import select import signal import stat import time COMPAT = Path('/tmp/erri-headless-compat') INSTALLED_PROTON = Path.home()/'.local/share/Steam/steamapps/common/Proton - Experimental/proton' PREFIX = b'/tmp/erri-headless-compat/pfx' DEVICE_ARGV = (b'C:\\windows\\system32\\winedevice.exe',) SYSTEMD_ARGV = (b'/usr/lib/systemd/systemd', b'--user') class CleanupRefused(ValueError): def __init__(self, message, report=None): super().__init__(message) self.report = report or {} @dataclass(frozen=True) class Process: pid: int uid: int ppid: int start: int state: str comm: str argv: tuple exe: str | None executable_id: tuple | None prefix: bytes | None = None def identity(self): # State may legitimately change between running and sleeping. return (self.pid, self.uid, self.ppid, self.start, self.comm, self.argv, self.exe, self.executable_id) @dataclass(frozen=True) class Device: process: Process parent: Process def identity(self): return self.process.identity(), self.parent.identity() def public(self): p = self.process return {'pid': p.pid, 'ppid': p.ppid, 'state': p.state, 'exebasename': 'wine64-preloader', 'start_ticks': p.start, 'role': 'adopted-winedevice'} def prefix_kind(value): if value is None: return 'other' if value.rstrip(b'/') == PREFIX: return 'exact' # Never resolve untrusted environment paths or follow their symlinks. # A lexical spelling of this prefix is ambiguous and cannot be killed. if posixpath.normpath(value) == PREFIX: return 'alias' return 'other' def user_systemd_argv(argv): if argv == SYSTEMD_ARGV: return True # User-systemd preserves its identity across reexec and appends its state # descriptor. Accept only the observed single ASCII decimal FD argument. if len(argv) != 3 or argv[:2] != SYSTEMD_ARGV: return False argument = argv[2] if not argument.startswith(b'--deserialize='): return False descriptor = argument[len(b'--deserialize='):] return (descriptor.isdigit() and len(descriptor) <= 10 and int(descriptor) <= 2147483647) def native_metadata_proves_unrelated(p): names = [p.comm.lower(), *(os.fsdecode(a).replace('\\', '/').rsplit('/', 1)[-1].lower() for a in p.argv)] if p.exe: names.append(Path(p.exe).name.lower()) if any(n.endswith('.exe') or n.startswith(('wine', 'proton', 'gamescope', 'armareforger')) or n in {'reaper', 'pressure-vessel-wrap', 'pv-bwrap'} for n in names): return False if (p.comm == 'systemd' or p.exe == '/usr/lib/systemd/systemd' or (p.argv and p.argv[0] == SYSTEMD_ARGV[0])): return (p.comm == 'systemd' and p.ppid == 1 and user_systemd_argv(p.argv) and p.exe in {None, '/usr/lib/systemd/systemd'}) if p.exe and p.exe.startswith('/'): return True if p.comm == '(sd-pam)' and p.argv == (b'(sd-pam)',): return True # Same narrow native foreground signature as headless_game's preflight. if p.comm == 'apache2' and p.argv == (b'apache2', b'-DFOREGROUND'): return True # The user's GnuPG smart-card helper is native and may be nondumpable. if p.comm == 'scdaemon' and p.argv == (b'scdaemon', b'--multi-server'): return True # Nondumpable desktop services still expose argv and kernel comm. Match # both (Linux comm truncates at 15 bytes), after refusing runtime markers # in every argument above. This does not authorize any signal: it only # skips an unreadable environment for positively native metadata. if p.argv and p.argv[0].startswith(b'/'): basename = os.fsdecode(p.argv[0]).rsplit('/', 1)[-1] if p.comm and p.comm in {basename, basename[:15]}: return True return False def prove_device(p, parent, uid, expected_exe, executable_id): if (p.uid != uid or p.state in {'Z', 'X'} or prefix_kind(p.prefix) != 'exact' or p.comm != 'winedevice.exe' or p.argv != DEVICE_ARGV or p.exe != expected_exe or p.executable_id != executable_id): raise CleanupRefused(f'PID {p.pid} is not the exact adopted device helper; left untouched') if (parent.pid != p.ppid or parent.uid != uid or parent.ppid != 1 or parent.state in {'Z', 'X'} or parent.comm != 'systemd' or not user_systemd_argv(parent.argv) or parent.exe not in {None, '/usr/lib/systemd/systemd'}): raise CleanupRefused(f'PID {p.pid} does not have the proven user-systemd parent; left untouched') # The observed user-systemd is nondumpable: an unreadable exe is accepted # only with all exact parent metadata above. A readable wrong exe is refused. return Device(p, parent) class ProcFS: def __init__(self, expected_exe, executable_id, root=Path('/proc'), uid=None, self_pid=None): self.root = root self.uid = os.getuid() if uid is None else uid self.self_pid = os.getpid() if self_pid is None else self_pid self.expected_exe = str(expected_exe) self.executable_id = executable_id def metadata(self, pid): p = self.root / str(pid) uid = p.stat().st_uid text = (p/'stat').read_text() fields = text[text.rfind(')') + 2:].split() state, ppid, start = fields[0], int(fields[1]), int(fields[19]) comm = (p/'comm').read_text().strip() argv = tuple(a for a in (p/'cmdline').read_bytes().split(b'\0') if a) try: exe = os.readlink(p/'exe') info = (p/'exe').stat() executable_id = (info.st_dev, info.st_ino) except PermissionError: exe, executable_id = None, None # Detect PID reuse while reading the separate proc records. final = (p/'stat').read_text() after = final[final.rfind(')') + 2:].split() if int(after[19]) != start or int(after[1]) != ppid: raise CleanupRefused(f'PID {pid} changed while reading metadata; left untouched') return Process(pid, uid, ppid, start, state, comm, argv, exe, executable_id) def devices(self): found = [] for entry in self.root.iterdir(): if not entry.name.isdigit() or int(entry.name) == self.self_pid: continue # library caller is this process, never another PID pid = int(entry.name) try: if entry.stat().st_uid != self.uid: continue p = self.metadata(pid) if p.state in {'Z', 'X'}: continue # already exited, not a signal target try: values = [v[11:] for v in (entry/'environ').read_bytes().split(b'\0') if v.startswith(b'WINEPREFIX=')] except PermissionError: if native_metadata_proves_unrelated(p): continue raise CleanupRefused(f'Cannot establish prefix ownership for PID {pid}; left untouched') kinds = [prefix_kind(value) for value in values] if not any(k in {'exact', 'alias'} for k in kinds): continue if len(values) != 1 or kinds[0] != 'exact': raise CleanupRefused(f'PID {pid} has ambiguous private-prefix spelling; left untouched') again = self.metadata(pid) if again.identity() != p.identity(): raise CleanupRefused(f'PID {pid} changed during prefix inspection; left untouched') p = Process(**{**p.__dict__, 'prefix': values[0]}) parent = self.metadata(p.ppid) found.append(prove_device(p, parent, self.uid, self.expected_exe, self.executable_id)) except (FileNotFoundError, ProcessLookupError): continue except (PermissionError, OSError) as error: raise CleanupRefused(f'Cannot verify private-prefix PID {pid}: {type(error).__name__}; left untouched') from None return sorted(found, key=lambda d: d.process.pid) class Pidfds: def open(self, pid): return os.pidfd_open(pid) def close(self, fd): os.close(fd) def send(self, fd, sig): signal.pidfd_send_signal(fd, sig) def wait(self, handles, timeout): poller = select.poll() by_fd = {fd: pid for pid, fd in handles.items()} for fd in by_fd: poller.register(fd, select.POLLIN) finished = set() until = time.monotonic() + timeout while len(finished) < len(handles): remaining = max(0, until-time.monotonic()) events = poller.poll(int(remaining*1000)) for fd, flags in events: if flags & (select.POLLIN | select.POLLHUP): finished.add(by_fd[fd]) poller.unregister(fd) elif flags & (select.POLLERR | select.POLLNVAL): raise CleanupRefused('A pinned process handle became unverifiable') if remaining <= 0 or time.monotonic() >= until: break return finished def _cleanup(inspector, signals): """Testable algorithm; production entry below always supplies real guards.""" initial = {d.process.pid: d for d in inspector.devices()} report = {'scope': str(COMPAT/'pfx'), 'candidates': [d.public() for d in initial.values()], 'term_sent': [], 'kill_sent': [], 'cleared': False} handles = {} def census(): current = {d.process.pid: d for d in inspector.devices()} for pid, candidate in current.items(): if pid not in initial or candidate.identity() != initial[pid].identity(): raise CleanupRefused(f'Private-prefix process identity changed (PID {pid}); remaining processes left untouched') return current try: # Open every handle only after the complete prefix census proves there # are no active/unknown processes. Before signalling, verify it again. for pid in initial: try: handles[pid] = signals.open(pid) except ProcessLookupError: pass for pid, fd in handles.items(): current = census() if pid in signals.wait({pid: fd}, 0): continue if pid not in current: raise CleanupRefused(f'Pinned PID {pid} lost its proven prefix identity; left untouched') try: signals.send(fd, signal.SIGTERM) report['term_sent'].append(pid) except ProcessLookupError: pass # pinned original exited, never retry via its numeric PID exited = signals.wait(handles, 3.0) for pid, fd in handles.items(): if pid in exited: continue current = census() if pid in signals.wait({pid: fd}, 0): continue if pid not in current: raise CleanupRefused(f'Pinned PID {pid} lost its proven prefix identity; left untouched') try: signals.send(fd, signal.SIGKILL) report['kill_sent'].append(pid) except ProcessLookupError: pass signals.wait(handles, 1.0) if census(): raise CleanupRefused('Some pinned private-prefix helpers remain alive; no broader cleanup attempted') report['cleared'] = True return report except (CleanupRefused, OSError) as error: raise CleanupRefused(str(error), report) from None finally: for fd in handles.values(): signals.close(fd) def _inspector(compat, proton): compat, proton = Path(compat), Path(proton) if compat != COMPAT or compat.resolve() != COMPAT or (compat/'pfx').resolve() != COMPAT/'pfx': raise CleanupRefused('Cleanup is restricted to the canonical disposable /tmp/erri-headless-compat/pfx') for path in (compat, compat/'pfx'): if not path.is_dir() or path.stat().st_uid != os.getuid(): raise CleanupRefused('Private prefix must be an existing directory owned by this user') expected = proton.parent/'files/lib/wine/i386-unix/wine64-preloader' if proton != INSTALLED_PROTON or not proton.is_file() or not expected.is_file() or expected.resolve() != expected: raise CleanupRefused('Verified installed Proton device-helper executable required') info = expected.stat() if not stat.S_ISREG(info.st_mode): raise CleanupRefused('Proton device-helper executable is not a regular file') if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'): raise CleanupRefused('PID-pinned signalling is unavailable; no PID-only fallback') return ProcFS(expected, (info.st_dev, info.st_ino)) def inspect_private_prefix(compat, proton): """Read-only proof; refuses if anything except exact adopted helpers remains.""" return [d.public() for d in _inspector(compat, proton).devices()] def cleanup_private_prefix(compat, proton): """After ordinary shutdown, with the shared prefix lock still held. Never call as startup recovery for a busy prefix. The caller owns the disposed test session; any other live process makes this fail closed. """ return _cleanup(_inspector(compat, proton), Pidfds())