Files
EscapistsReintroduced/tools/private_prefix_cleanup.py
T

302 lines
13 KiB
Python

#!/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 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.exe and p.exe.startswith('/'):
return True
if p.comm == 'systemd' and p.argv == SYSTEMD_ARGV and p.ppid == 1:
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
# 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 parent.argv != SYSTEMD_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())