302 lines
13 KiB
Python
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())
|