Improve base guidance and qualify bounded aircraft route and hidden test lifecycle

This commit is contained in:
2026-09-19 16:41:32 +02:00
parent 6f0e4c5978
commit 5a24eea2f5
192 changed files with 27066 additions and 46 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ except (OSError, ValueError) as error:
dest=ROOT/'docs/evidence'/a.name
try:dest.mkdir(parents=True,exist_ok=False)
except FileExistsError:p.error('Evidence already exists; refusing to overwrite')
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json']:
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json','display-ownership.json']:
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
for capture in sorted([*source.glob('err-*.png'), *source.glob('err-*.bmp')]):
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
+1 -1
View File
@@ -59,7 +59,7 @@ def check():
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
for name in ['test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
for name in ['test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
if __name__=='__main__':check()
+726
View File
@@ -0,0 +1,726 @@
#!/usr/bin/env python3
"""Read-only X11 ownership observer, executed INSIDE an owned headless Gamescope.
No input/focus API, no engine launch of its own, no desktop DISPLAY fallback.
"""
import argparse
import ctypes as C
import hashlib
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import time
PROFILE_ID = {'kind': 'erri-ordinary-game-disposable-profile', 'version': 1}
MAX_WINDOWS = 1024
def display_number(value):
match = re.fullmatch(r'(?:unix)?:([0-9]{1,5})(?:\.([0-9]{1,3}))?', value or '')
if not match:
raise ValueError('DISPLAY must be explicit local Unix :N[.screen]')
return int(match[1])
def process(pid, proc_root=Path('/proc')):
p = proc_root / str(pid)
fields = (p/'stat').read_text().rsplit(')', 1)[1].split()
return {'pid': int(pid), 'ppid': int(fields[1]), 'start': int(fields[19]),
'uid': p.stat().st_uid, 'exe': os.readlink(p/'exe'),
'args': [v.decode('utf-8', 'strict') for v in (p/'cmdline').read_bytes().split(b'\0') if v]}
def environment(pid, proc_root=Path('/proc')):
# Only allowlisted values leave this function; never serialize the environment.
values = {}
for value in (proc_root/str(pid)/'environ').read_bytes().split(b'\0'):
key, _, content = value.partition(b'=')
if key in (b'DISPLAY', b'WINEPREFIX', b'PULSE_PROP'):
values[key.decode()] = content.decode('utf-8', 'strict')
return values
def prefix_matches(value, expected):
"""Proton's pfx/ spelling only; do not normalize aliases or path components."""
return isinstance(value, str) and value.rstrip('/') == str(expected)
def identity(p):
return {key: p[key] for key in ('pid', 'start', 'uid')}
def headless_args(args):
options = args[1:args.index('--')] if '--' in args else []
return (options.count('--backend') == 1 and
options[options.index('--backend') + 1:options.index('--backend') + 2] == ['headless'] and
not any(value.startswith('--backend=') for value in options))
def ancestor(pid, read=process):
seen = set()
for _ in range(64):
if pid < 2 or pid in seen:
break
seen.add(pid)
p = read(pid)
if p['uid'] != os.getuid():
raise ValueError('Foreign-user ancestor')
if Path(p['exe']).name == 'gamescope':
if not headless_args(p['args']):
raise ValueError('Gamescope ancestor is not explicitly headless')
return p
pid = p['ppid']
raise ValueError('No owned headless Gamescope ancestor')
def descendant(pid, parent, read=process):
for _ in range(64):
if pid == parent:
return True
if pid < 2:
break
pid = read(pid)['ppid']
return False
def validate_display(display, host_display):
number = display_number(display)
# Host DISPLAY can be remote; never open it. Also reject localhost aliases.
host_match = re.fullmatch(r'(?:unix|localhost)?:(\d+)(?:\.\d+)?', host_display or '')
if display == host_display or (host_match and int(host_match[1]) == number):
raise ValueError('Nested DISPLAY is the supplied host display')
return number
def owned_sockets(number, pid, proc_root=Path('/proc')):
"""Prove every local Xlib connection address for this number is owned, before XOpenDisplay."""
paths = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
sockets = []
for line in (proc_root/'net/unix').read_text().splitlines()[1:]:
fields = line.split()
if len(fields) < 8 or fields[7] not in paths:
continue
if fields[4] != '0001' or fields[5] != '01' or not int(fields[3], 16) & 0x10000:
continue
sockets.append({'path':fields[7], 'inode':int(fields[6])})
if not sockets or len({item['path'] for item in sockets}) != len(sockets):
raise ValueError('No unique live local X11 listening socket')
fds = set()
for fd in (proc_root/str(pid)/'fd').iterdir():
try:
target = os.readlink(fd)
except FileNotFoundError:
continue
match = re.fullmatch(r'socket:\[(\d+)\]', target)
if match:
fds.add(int(match[1]))
if any(item['inode'] not in fds for item in sockets):
raise ValueError('Local display socket is not held by owned Xwayland')
return sorted(sockets, key=lambda item:item['path'])
def candidate_relationship(p, scope, number, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
"""Independent, sanitized process/socket checks for reparented Xwayland."""
expected = os.environ.get('PULSE_PROP', '')
valid_nonce = bool(re.fullmatch(r'application.id=org\.escapistsrereintroduced\.game\.[0-9a-f]{32}', expected))
result = {'start_not_older_than_gamescope':p['start'] >= scope['start'],
'wrapper_run_nonce_valid':valid_nonce,
'xwayland_prefix_matches':None, 'gamescope_prefix_matches':None,
'xwayland_run_nonce_matches':None, 'gamescope_run_nonce_matches':None,
'xwayland_holds_all_display_listeners':False, 'gamescope_holds_all_display_listeners':False,
'same_listener_inodes':False, 'listener_inodes':[], 'process_identities_stable':False}
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
try:
env = env_read(pid)
result[role+'_prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
result[role+'_run_nonce_matches'] = valid_nonce and env.get('PULSE_PROP') == expected
except (OSError, ValueError, UnicodeError):
pass
listeners = {}
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
try:
listeners[role] = owned_sockets(number,pid,proc_root)
result[role+'_holds_all_display_listeners'] = True
except (OSError, ValueError):
pass
if 'xwayland' in listeners:
result['listener_inodes'] = [item['inode'] for item in listeners['xwayland']]
result['same_listener_inodes'] = bool(listeners.get('xwayland')) and listeners.get('xwayland') == listeners.get('gamescope')
try:
result['process_identities_stable'] = identity(read(p['pid'])) == identity(p) and identity(read(scope['pid'])) == identity(scope)
except (OSError, ValueError):
pass
return result
SHARED_CHECKS = (
'start_not_older_than_gamescope', 'wrapper_run_nonce_valid',
'xwayland_prefix_matches', 'gamescope_prefix_matches',
'xwayland_run_nonce_matches', 'gamescope_run_nonce_matches',
'xwayland_holds_all_display_listeners', 'gamescope_holds_all_display_listeners',
'same_listener_inodes', 'process_identities_stable',
)
def shared_relationship_valid(details):
if not isinstance(details, dict) or not all(details.get(key) is True for key in SHARED_CHECKS):
return False
inodes = details.get('listener_inodes')
return (isinstance(inodes, list) and 1 <= len(inodes) <= 2 and
all(type(inode) is int and inode > 0 for inode in inodes) and len(set(inodes)) == len(inodes))
def binding(host_display, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
display = os.environ.get('DISPLAY', '')
number = validate_display(display, host_display)
if not prefix_matches(os.environ.get('WINEPREFIX'),prefix):
raise ValueError('Wrapper prefix does not match supplied private prefix')
scope = ancestor(os.getppid(), read)
matches = []
candidates = []
candidate_count = 0
unreadable = 0
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
p = read(int(entry.name))
if p['uid'] != os.getuid():
continue
basename = Path(p['exe']).name
argv_name = Path(p['args'][0]).name if p['args'] else ''
# Broader names are DIAGNOSTICS ONLY. The authorization check below
# still requires the exact native executable name and an owned relationship.
if 'xwayland' not in basename.lower() and 'xwayland' not in argv_name.lower():
continue
candidate_count += 1
displays = [v for v in p['args'][1:] if re.fullmatch(r':\d+(?:\.\d+)?', v)]
portal = any(v == '-enable-ei-portal' or v.startswith('-enable-ei-portal=') for v in p['args'])
try:
nested = descendant(p['pid'], scope['pid'], read)
except (FileNotFoundError, ProcessLookupError, PermissionError):
nested = None
relationship = None
if len(displays) == 1 and display_number(displays[0]) == number:
relationship = candidate_relationship(p,scope,number,prefix,read,env_read,proc_root)
if len(candidates) < 16:
candidate = {**identity(p), 'ppid':p['ppid'],
'executable_basename':basename[:96],
'display_args':displays[:4], 'display_arg_count':len(displays),
'portal_enabled':portal, 'descendant':nested,
'exact_executable_name':basename == 'Xwayland'}
if relationship is not None:
candidate['selected_display_relationship'] = relationship
candidates.append(candidate)
if basename != 'Xwayland':
continue
if len(displays) != 1 or display_number(displays[0]) != number:
continue
if portal:
raise ValueError('Nested Xwayland has input-emulation portal enabled')
if nested is True:
matches.append((p, {'mode':'descendant', 'descendant':True}))
elif shared_relationship_valid(relationship):
matches.append((p, {'mode':'shared-listeners', 'checks':relationship}))
except (FileNotFoundError, ProcessLookupError, PermissionError):
unreadable += 1
continue
if len(matches) != 1:
diagnostic = {'display':display, 'gamescope':{**identity(scope),'ppid':scope['ppid']},
'matching_candidates':len(matches), 'same_user_xwayland_candidates':candidates,
'candidate_count':candidate_count, 'candidate_limit':16,
'unreadable_process_count':unreadable,
'read_only':True, 'display_opened':False, 'input_sent':False}
raise ValueError('Cannot identify exactly one owned nested Xwayland; preflight=' +
json.dumps(diagnostic,sort_keys=True,separators=(',',':')))
selected, ownership = matches[0]
sockets = owned_sockets(number, selected['pid'], proc_root)
for pinned in (selected, scope):
current = read(pinned['pid'])
if identity(current) != identity(pinned) or current['exe'] != pinned['exe'] or current['args'] != pinned['args']:
raise ValueError('Native process changed during socket ownership check')
if ownership['mode'] == 'shared-listeners':
final_checks = candidate_relationship(selected,scope,number,prefix,read,env_read,proc_root)
if (not shared_relationship_valid(final_checks) or final_checks != ownership['checks'] or
final_checks['listener_inodes'] != [item['inode'] for item in sockets]):
raise ValueError('Shared listener ownership changed during binding')
elif not descendant(selected['pid'],scope['pid'],read):
raise ValueError('Native descendant ownership changed during binding')
return {'display': display, 'gamescope': identity(scope), 'xwayland': identity(selected),
'sockets': sockets, 'relationship': ownership}
def executable_argument(args, executable):
# Match an exact full PE path, not a basename/title/comm guess.
accepted = {str(executable), 'Z:'+str(executable), 'Z:'+str(executable).replace('/', '\\')}
return bool(args) and args[0] in accepted
def owned_window(window, bound, prefix, executable, read=process, env_read=environment):
pid = window.get('pid')
if type(pid) is not int or pid <= 0 or pid != window.get('server_pid'):
return None
try:
before = read(pid)
if before['uid'] != os.getuid() or not executable_argument(before['args'], executable):
return None
env = env_read(pid)
if not prefix_matches(env.get('WINEPREFIX'),prefix) or env.get('DISPLAY') != bound['display']:
return None
if identity(read(pid)) != identity(before):
return None
except (OSError, ValueError, UnicodeError):
return None
return {**window, 'process': identity(before)}
def window_process_diagnostic(pid, bound, prefix, executable, read=process, env_read=environment):
result = {'readable':False, 'same_user':None, 'game_executable_match':False,
'argv0_class':'unreadable', 'argv0_case_insensitive_exact':False,
'prefix_matches':None, 'display_matches':None, 'process_stable':False}
if type(pid) is not int or pid <= 0:
result['argv0_class'] = 'invalid-pid'
return result
try:
before = read(pid)
result['readable'] = True
result['same_user'] = before['uid'] == os.getuid()
argv0 = before['args'][0] if before['args'] else ''
exact = executable_argument(before['args'],executable)
result['game_executable_match'] = exact
if exact:
result['argv0_class'] = 'exact-unix-game' if argv0 == str(executable) else 'exact-wine-z-game'
elif not argv0:
result['argv0_class'] = 'empty'
elif re.split(r'[\\/]',argv0)[-1].lower() == executable.name.lower():
result['argv0_class'] = 'game-basename-noncanonical-path'
elif argv0.lower().endswith('.exe'):
result['argv0_class'] = 'other-windows-executable'
else:
result['argv0_class'] = 'other'
normalized = argv0.replace('\\','/').lower()
result['argv0_case_insensitive_exact'] = normalized in {str(executable).lower(),('Z:'+str(executable)).lower()}
env = env_read(pid)
result['prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
result['display_matches'] = env.get('DISPLAY') == bound['display']
result['process_stable'] = identity(read(pid)) == identity(before)
except (OSError, ValueError, UnicodeError):
pass
return result
def window_diagnostic(window, bound, prefix, executable, read=process, env_read=environment):
pid, server = window.get('pid'), window.get('server_pid')
property_process = window_process_diagnostic(pid,bound,prefix,executable,read,env_read)
server_process = property_process if pid == server else window_process_diagnostic(server,bound,prefix,executable,read,env_read)
if type(pid) is not int or pid <= 0:
stage = 'invalid-property-pid'
elif type(server) is not int or server <= 0:
stage = 'server-pid-unavailable'
elif pid != server:
stage = 'property-server-pid-disagreement'
elif not property_process['readable']:
stage = 'native-process-unreadable'
elif property_process['same_user'] is not True:
stage = 'foreign-user'
elif not property_process['game_executable_match']:
stage = 'game-executable-mismatch'
elif property_process['prefix_matches'] is not True:
stage = 'private-prefix-unverified'
elif property_process['display_matches'] is not True:
stage = 'private-display-unverified'
elif not property_process['process_stable']:
stage = 'native-process-changed'
else:
stage = 'checks-passed'
return {'window':window.get('window'), 'pid':pid, 'server_pid':server,
'rejection_stage':stage, 'property_process':property_process, 'server_process':server_process,
'xres_query':window.get('xres_query')}
class WindowAttributes(C.Structure):
# Exact installed Xlib.h layout; Bool is int and XIDs are unsigned long.
_fields_ = [('x',C.c_int),('y',C.c_int),('width',C.c_int),('height',C.c_int),
('border_width',C.c_int),('depth',C.c_int),('visual',C.c_void_p),
('root',C.c_ulong),('window_class',C.c_int),('bit_gravity',C.c_int),
('win_gravity',C.c_int),('backing_store',C.c_int),('backing_planes',C.c_ulong),
('backing_pixel',C.c_ulong),('save_under',C.c_int),('colormap',C.c_ulong),
('map_installed',C.c_int),('map_state',C.c_int),('all_event_masks',C.c_long),
('your_event_mask',C.c_long),('do_not_propagate_mask',C.c_long),
('override_redirect',C.c_int),('screen',C.c_void_p)]
def mapped_geometry(query, window, root):
attrs = query.attributes(window)
root_attrs = query.attributes(root)
for item in (attrs,root_attrs):
if (not isinstance(item,dict) or any(type(item.get(key)) is not int for key in
('root','window_class','map_state','width','height')) or
item['root'] != root or item['window_class'] != 1 or item['map_state'] != 2 or
not 0 < item['width'] <= 65535 or not 0 < item['height'] <= 65535):
raise ValueError('unmapped-or-invalid-window-attributes')
x,y = query.translate(window,root)
if type(x) is not int or type(y) is not int:
raise ValueError('invalid-window-root-coordinates')
clipped = [max(0,x),max(0,y),min(root_attrs['width'],x+attrs['width']),min(root_attrs['height'],y+attrs['height'])]
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
raise ValueError('window-outside-private-root')
return {'window':window,'root':root,'map_state':attrs['map_state'],'window_class':attrs['window_class'],
'x':x,'y':y,'width':attrs['width'],'height':attrs['height'],
'root_width':root_attrs['width'],'root_height':root_attrs['height'],'clipped_bounds':clipped}
def input_target(query, owner):
"""Point-in-time read-only mapped/focus evidence; never acquires focus."""
result = {'window':owner.get('window'),'verified':False,'reason':'not-observed'}
try:
if any(type(owner.get(key)) is not int or owner[key] <= 1 for key in ('window','pid')):
raise ValueError('invalid-owned-window-identity')
root = query.root()
if type(root) is not int or root <= 1:
raise ValueError('invalid-private-root')
geometry = mapped_geometry(query,owner['window'],root)
result['geometry'] = geometry
focused,revert = query.focus()
if type(focused) is not int or focused <= 1 or type(revert) is not int or revert not in (0,1,2):
raise ValueError('no-concrete-existing-focus')
result['focus_window'] = focused
result['focus_revert_to'] = revert
chain = []; current = focused
clipped = list(geometry['clipped_bounds'])
for _ in range(32):
if current in chain or current == root or current <= 1:
raise ValueError('focus-outside-owned-window-tree')
client_pid = query.server_pid(current)
if type(client_pid) is not int or client_pid != owner['pid']:
raise ValueError('focus-client-not-owned-game')
child_geometry = mapped_geometry(query,current,root)
bounds = child_geometry['clipped_bounds']
clipped = [max(clipped[0],bounds[0]),max(clipped[1],bounds[1]),min(clipped[2],bounds[2]),min(clipped[3],bounds[3])]
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
raise ValueError('focus-has-no-visible-owned-intersection')
chain.append(current)
if current == owner['window']:
break
tree_root,parent,_ = query.tree(current)
if tree_root != root or type(parent) is not int:
raise ValueError('focus-tree-root-mismatch')
current = parent
else:
raise ValueError('focus-ancestry-budget-exceeded')
if query.focus() != (focused,revert) or mapped_geometry(query,owner['window'],root) != geometry:
raise ValueError('focus-or-geometry-changed-during-observation')
result.update(verified=True,reason='owned-mapped-existing-focus',focus_chain=chain,
focused_clipped_bounds=clipped)
except (OSError,ValueError,TypeError,KeyError) as error:
result['reason'] = str(error)[:160]
return result
class X11:
"""Explicit display connection; all exported methods are read-only queries."""
class Spec(C.Structure):
_fields_ = [('client', C.c_ulong), ('mask', C.c_uint)]
class Client(C.Structure):
pass
def __init__(self, display):
self.x = C.CDLL('libX11.so.6')
self.r = C.CDLL('libXRes.so.1')
self.d = None
self.query_budget = MAX_WINDOWS
signatures = {
'XOpenDisplay': (C.c_void_p, [C.c_char_p]),
'XCloseDisplay': (C.c_int, [C.c_void_p]),
'XDefaultRootWindow': (C.c_ulong, [C.c_void_p]),
'XGetWindowAttributes': (C.c_int,[C.c_void_p,C.c_ulong,C.POINTER(WindowAttributes)]),
'XGetInputFocus': (C.c_int,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)]),
'XTranslateCoordinates': (C.c_int,[C.c_void_p,C.c_ulong,C.c_ulong,C.c_int,C.c_int,C.POINTER(C.c_int),C.POINTER(C.c_int),C.POINTER(C.c_ulong)]),
'XInternAtom': (C.c_ulong, [C.c_void_p, C.c_char_p, C.c_int]),
'XFree': (C.c_int, [C.c_void_p]),
'XQueryTree': (C.c_int, [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)), C.POINTER(C.c_uint)]),
'XGetWindowProperty': (C.c_int, [C.c_void_p, C.c_ulong, C.c_ulong, C.c_long, C.c_long, C.c_int, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_int), C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.c_void_p)]),
}
for name, (ret, args) in signatures.items():
method = getattr(self.x, name); method.restype = ret; method.argtypes = args
self.r.XResQueryClientIds.argtypes = [C.c_void_p, C.c_long, C.POINTER(self.Spec), C.POINTER(C.c_long), C.POINTER(C.POINTER(self.Client))]
self.r.XResQueryClientIds.restype = C.c_int
self.r.XResGetClientPid.argtypes = [C.POINTER(self.Client)]
self.r.XResGetClientPid.restype = C.c_int
self.r.XResClientIdsDestroy.argtypes = [C.c_long, C.POINTER(self.Client)]
self.r.XResClientIdsDestroy.restype = None
self.d = self.x.XOpenDisplay(display.encode('ascii'))
if not self.d:
raise ValueError('Explicit nested XOpenDisplay failed')
def close(self):
if self.d:
self.x.XCloseDisplay(self.d); self.d = None
def prop(self, window, name):
atom = self.x.XInternAtom(self.d, name.encode(), 1)
if not atom:
return None
kind, fmt, count, after, data = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong(), C.c_void_p()
status = self.x.XGetWindowProperty(self.d, window, atom, 0, 256, 0, 0,
C.byref(kind), C.byref(fmt), C.byref(count), C.byref(after), C.byref(data))
try:
if status or after.value or not data.value:
return None
if name == '_NET_WM_PID':
cardinal = self.x.XInternAtom(self.d, b'CARDINAL', 1)
if fmt.value == 32 and count.value == 1 and kind.value == cardinal:
return C.cast(data, C.POINTER(C.c_ulong))[0]
return None
if fmt.value == 8 and count.value <= 1024:
return C.string_at(data, count.value).decode('utf-8', 'replace')[:256]
return None
finally:
if data.value:
self.x.XFree(data)
def server_pid(self, window):
spec = self.Spec(window, 2) # XRES_CLIENT_ID_PID_MASK, installed XRes.h
count, values = C.c_long(), C.POINTER(self.Client)()
# This installed XRes API returns Success (0), unlike XQueryTree's Bool.
status = self.r.XResQueryClientIds(self.d, 1, C.byref(spec), C.byref(count), C.byref(values))
if not hasattr(self,'pid_diagnostics'):
self.pid_diagnostics = {}
diagnostic = {'status':int(status),'count':count.value,'mask':None}
self.pid_diagnostics[window] = diagnostic
if status != 0:
return None
try:
if count.value == 1 and values:
diagnostic['mask'] = values[0].spec.mask
if count.value != 1 or not values or values[0].spec.mask != 2:
return None
pid = self.r.XResGetClientPid(values)
return pid if pid > 0 else None
finally:
self.r.XResClientIdsDestroy(count, values)
def budget(self):
self.query_budget -= 1
if self.query_budget < 0:
raise ValueError('native-read-query-budget-exceeded')
def root(self):
return self.x.XDefaultRootWindow(self.d)
def attributes(self,window):
self.budget()
attrs = WindowAttributes()
if not self.x.XGetWindowAttributes(self.d,window,C.byref(attrs)):
raise ValueError('native-window-attributes-unavailable')
return {key:getattr(attrs,key) for key in ('root','window_class','map_state','width','height')}
def focus(self):
self.budget()
window,revert = C.c_ulong(),C.c_int()
if not self.x.XGetInputFocus(self.d,C.byref(window),C.byref(revert)):
raise ValueError('native-input-focus-unavailable')
return window.value,revert.value
def translate(self,window,root):
self.budget()
x,y,child = C.c_int(),C.c_int(),C.c_ulong()
if not self.x.XTranslateCoordinates(self.d,window,root,0,0,C.byref(x),C.byref(y),C.byref(child)):
raise ValueError('native-window-root-translation-unavailable')
return x.value,y.value
def tree(self,window):
self.budget()
root,parent,children,count = C.c_ulong(),C.c_ulong(),C.POINTER(C.c_ulong)(),C.c_uint()
if not self.x.XQueryTree(self.d,window,C.byref(root),C.byref(parent),C.byref(children),C.byref(count)):
raise ValueError('Window tree changed during query')
try:
if count.value > MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
return root.value,parent.value,[children[i] for i in range(count.value)]
finally:
if children:
self.x.XFree(children)
def windows(self):
pending = [self.x.XDefaultRootWindow(self.d)]; seen = set(); found = []
while pending:
if len(seen) >= MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
window = pending.pop()
if window in seen:
raise ValueError('Unexpected repeated window tree entry')
seen.add(window)
pid = self.prop(window, '_NET_WM_PID')
if pid is not None:
server_pid = self.server_pid(window)
found.append({'window': window, 'pid': pid, 'server_pid': server_pid,
'xres_query':self.pid_diagnostics.get(window),
'title': self.prop(window, '_NET_WM_NAME') or self.prop(window, 'WM_NAME') or ''})
_,_,children = self.tree(window)
if len(children) + len(pending) + len(seen) > MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
pending.extend(children)
return found
X11.Client._fields_ = [('spec', X11.Spec), ('length', C.c_long), ('value', C.c_void_p)]
def observe(args, query_class=X11):
before = binding(args.host_display, args.prefix)
query = query_class(before['display'])
try:
windows = query.windows()
owned = [match for window in windows if (match := owned_window(window,before,args.prefix,args.game_executable))]
targets = [input_target(query,owner) for owner in owned[:16]]
# Recheck the exact native process after geometry/focus reads. Never
# promote a stale process identity into an input target.
for target,owner in zip(targets,owned):
current = owned_window(owner,before,args.prefix,args.game_executable)
if current is None or current['process'] != owner['process']:
target.update(verified=False,reason='game-process-changed-during-target-observation')
finally:
query.close()
if binding(args.host_display, args.prefix) != before:
raise ValueError('Display ownership changed during read-only query')
diagnostics = [window_diagnostic(window,before,args.prefix,args.game_executable) for window in windows[:16]]
valid_targets = [target for target in targets if target['verified']]
# Nested owned windows can both contain focus. Prefer the exact focused
# owned window; otherwise require exactly one unambiguous owned ancestor.
exact_targets = [target for target in valid_targets if target.get('focus_window') == target['window']]
chosen = exact_targets if exact_targets else valid_targets
return {'binding':before,'owned_windows':owned,'owned_game_window_verified':bool(owned),
'input_target_verified':len(chosen) == 1,'input_target':chosen[0] if len(chosen) == 1 else None,
'input_target_observations':targets,'input_target_limit':16,
'window_count':len(windows),'window_diagnostic_limit':16,'window_diagnostics':diagnostics,
'unverified_window_count':len(windows)-len(owned)}
def validate_paths(args):
for path in (args.prefix, args.game_executable, args.profile):
if not path.is_absolute() or path != path.resolve() or not path.exists():
raise ValueError('Expected existing canonical runtime paths')
if not args.prefix.is_dir() or not args.game_executable.is_file() or not args.profile.is_dir():
raise ValueError('Invalid prefix, executable or profile type')
if not args.profile.is_relative_to('/tmp') or args.profile == Path('/tmp'):
raise ValueError('Report profile must be an owned private /tmp profile')
marker = args.profile/'.erri-game-profile.json'
if marker.is_symlink() or json.loads(marker.read_text()) != PROFILE_ID:
raise ValueError('Profile is not marked as disposable ordinary-game profile')
if (not args.report.is_absolute() or args.report.parent != args.profile or
args.report.name in ('', '.', '..') or args.report.is_symlink() or (args.report.exists() and not args.query_worker)):
raise ValueError('Report must be an exclusively new direct child of private profile')
if args.profile.stat().st_uid != os.getuid() or not 1 <= args.observe_seconds <= 30:
raise ValueError('Invalid report owner or observation bound')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host-display', required=True)
parser.add_argument('--prefix', type=Path, required=True)
parser.add_argument('--game-executable', type=Path, required=True)
parser.add_argument('--profile', type=Path, required=True)
parser.add_argument('--report', type=Path, required=True)
parser.add_argument('--observe-seconds', type=float, default=20)
parser.add_argument('--query-worker', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('command', nargs=argparse.REMAINDER)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
validate_paths(args)
bound = binding(args.host_display, args.prefix)
if args.query_worker:
print(json.dumps(observe(args)))
return 0
command = args.command[1:] if args.command[:1] == ['--'] else args.command
if (len(command) < 3 or Path(command[0]).name != 'proton' or
not Path(command[0]).is_absolute() or not Path(command[0]).is_file() or
command[1] != 'run' or command[2] != str(args.game_executable)):
raise ValueError('Wrapper command must contain the exact expected game executable')
# A new report is a prerequisite for starting the requested child. No overwrite.
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
report = {'schema': 1, 'kind': 'headless-display-read-only', 'binding': bound,
'owned_game_window_verified': False, 'input_target_verified': False, 'read_only': True, 'input_sent': False, 'release_qualified': False,
'scope': 'Read-only private X11 process/window ownership; no input or gameplay proof',
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'wrapper': identity(process(os.getpid())), 'command': command,
'prefix': str(args.prefix), 'profile': str(args.profile),
'game_executable': str(args.game_executable), 'report': str(args.report)}
report.update(query_attempts=0,successful_queries=0,first_window_count=None,last_window_count=None,
first_query_error=None,last_query_error=None,last_query_status='not-started')
child = None
previous = {}
received = []
def forward(signum, frame):
received.append(signum)
if child is not None and child.poll() is None:
child.send_signal(signum)
try:
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[signum] = signal.signal(signum, forward)
child = subprocess.Popen(command) # inherit existing owned process group and streams
for signum in received:
if child.poll() is None:
child.send_signal(signum)
deadline = time.monotonic() + args.observe_seconds
worker = [sys.executable, str(Path(__file__).resolve()), '--query-worker', '--host-display', args.host_display,
'--prefix', str(args.prefix), '--game-executable', str(args.game_executable),
'--profile', str(args.profile), '--report', str(args.report), '--observe-seconds', str(args.observe_seconds)]
while child.poll() is None and not received and time.monotonic() < deadline:
report['query_attempts'] += 1
try:
result = subprocess.run(worker, capture_output=True, text=True, timeout=min(3, max(0.01, deadline-time.monotonic())))
if result.returncode:
report['last_query_status'] = 'worker-exit'
report['last_query_error'] = 'Read-only worker failed; exit '+str(result.returncode)
if report['first_query_error'] is None:
report['first_query_error'] = report['last_query_error']
else:
observed = json.loads(result.stdout)
if observed.get('binding') != bound:
raise ValueError('Original display process identity changed')
report.update(observed)
report['successful_queries'] += 1
report['last_query_status'] = 'ok'
report['last_window_count'] = observed.get('window_count')
if report['first_window_count'] is None:
report['first_window_count'] = observed.get('window_count')
if report['owned_game_window_verified']:
break
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as error:
report['last_query_status'] = 'query-exception'
report['last_query_error'] = type(error).__name__
if report['first_query_error'] is None:
report['first_query_error'] = report['last_query_error']
time.sleep(0.2)
report['observation_finished'] = True
with os.fdopen(fd, 'w') as stream:
fd = None
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
returncode = child.wait() # do not alter engine lifetime; outer runner owns timeout/cleanup
finally:
if fd is not None:
os.close(fd)
for signum, handler in previous.items():
signal.signal(signum, handler)
if returncode < 0:
signum = -returncode
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
return returncode
if __name__ == '__main__':
try:
raise SystemExit(main())
except (OSError, ValueError) as error:
print('Display probe refused: '+str(error), file=sys.stderr)
raise SystemExit(2)
+301 -9
View File
@@ -12,6 +12,7 @@ import re
import shutil
import signal
import subprocess
import sys
import time
import uuid
from native_provenance import snapshot, persist, bindings, seal_run
@@ -309,7 +310,7 @@ def prefix_process_candidate(proc):
def assert_prefix_idle(compat, proc_root=Path('/proc')):
target = ('WINEPREFIX='+str(compat/'pfx')).encode()
target = str(compat/'pfx').encode()
failures = []
for proc in proc_root.iterdir():
if not proc.name.isdigit() or int(proc.name) == os.getpid():
@@ -325,7 +326,9 @@ def assert_prefix_idle(compat, proc_root=Path('/proc')):
except (OSError, ValueError) as error:
failures.append(f'Cannot verify disposable prefix is idle (PID {proc.name}): {error}')
continue
if target in environment:
# Proton assigns pfx/; only trailing separators are equivalent. Do not
# resolve untrusted process paths or accept sibling/symlink spellings.
if any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in environment):
failures.append(f'Disposable prefix already has a process (PID {proc.name}); leave it untouched')
if failures:
refuse('\n'.join(failures))
@@ -447,6 +450,42 @@ def cleanup(child, proton, env):
child.wait(timeout=10)
def cleanup_owned_run(child, proton, env, compat):
"""After a launched owned session, while the caller still holds the prefix lock.
Noncanonical private prefixes retain normal shutdown plus strict idle proof;
adopted-helper signalling is restricted to its one explicitly owned prefix.
"""
report = {'cleanup_confirmed':False, 'idle_verified':False, 'errors':[],
'orphan_cleanup':{'attempted':False,'reason':'noncanonical-private-prefix'}}
try:
cleanup(child,proton,env)
except (OSError,ValueError,subprocess.SubprocessError) as error:
report['errors'].append('normal shutdown: '+str(error))
canonical = Path('/tmp/erri-headless-compat')
if Path(compat) == canonical and Path(compat).resolve() == canonical:
report['orphan_cleanup'] = {'attempted':True}
try:
from private_prefix_cleanup import cleanup_private_prefix
details = cleanup_private_prefix(compat,proton)
report['orphan_cleanup']['report'] = details
if not isinstance(details,dict) or details.get('cleared') is not True:
raise ValueError('Private helper cleanup did not prove completion')
except (OSError,ValueError,ImportError,subprocess.SubprocessError) as error:
report['orphan_cleanup']['error'] = str(error)
details = getattr(error,'report',None)
if details is not None:
report['orphan_cleanup']['report'] = details
report['errors'].append('private helper cleanup: '+str(error))
try:
assert_prefix_idle(compat)
report['idle_verified'] = True
except (OSError,ValueError,subprocess.SubprocessError) as error:
report['errors'].append('idle verification: '+str(error))
report['cleanup_confirmed'] = report['idle_verified'] and not report['errors']
return report
class StartupMonitor:
"""Latch failures while allowing a bounded diagnostic drain; no native I/O."""
def __init__(self, started, timeout, settle, failure_grace=0):
@@ -494,10 +533,240 @@ class StartupMonitor:
return final
def display_probe_command(command, root, compat, profile, executable, run_id, host_display):
"""Wrap only the existing child of headless Gamescope; preserve Proton argv."""
separator = command.index('--')
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
refuse('Display inspection requires the explicit headless Gamescope boundary')
helper = root/'tools/headless_display_probe.py'
report = profile/('display-ownership-'+run_id+'.json')
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
refuse('Display inspection requires a regular helper and a new report')
child = command[separator+1:]
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
'command': child, 'host_display': host_display}
wrapper = [sys.executable, str(helper), '--host-display', host_display,
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
'--report', str(report), '--observe-seconds', '30', '--']
return command[:separator+1] + wrapper + child, context
def display_probe_result(context, session):
"""Retain exact observation bytes after cleanup; an absent/unverified report fails closed."""
result = {'requested': True, 'verified': False, 'read_only': True, 'input_sent': False}
try:
source = Path(context['report'])
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
refuse('Missing or invalid bounded display ownership report')
raw = source.read_bytes()
report = json.loads(raw)
result['sha256'] = hashlib.sha256(raw).hexdigest()
if session:
with (session/'display-ownership.json').open('xb') as output:
output.write(raw)
result['archive_file'] = 'display-ownership.json'
if not isinstance(report, dict):
refuse('Display ownership report is not an object')
for key in ('helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command'):
if report.get(key) != context[key]:
refuse('Display report binding mismatch: '+key)
if (report.get('schema') != 1 or report.get('kind') != 'headless-display-read-only'
or report.get('read_only') is not True or report.get('input_sent') is not False
or report.get('observation_finished') is not True or report.get('owned_game_window_verified') is not True):
refuse('Private display game-window ownership was not verified')
from headless_display_probe import validate_display, shared_relationship_valid
bound = report.get('binding', {})
number = validate_display(bound.get('display'), context['host_display'])
sockets = bound.get('sockets')
allowed = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
if not isinstance(sockets, list) or not sockets or len(sockets) > 2:
refuse('Missing private X11 socket ownership')
paths = set()
for sock in sockets:
if not isinstance(sock, dict) or sock.get('path') not in allowed or sock['path'] in paths or type(sock.get('inode')) is not int or sock['inode'] <= 0:
refuse('Invalid private X11 socket ownership')
paths.add(sock['path'])
for identity in [report.get('wrapper'), bound.get('gamescope'), bound.get('xwayland')]:
if not isinstance(identity, dict) or identity.get('uid') != os.getuid() or any(type(identity.get(k)) is not int or identity[k] <= 0 for k in ('pid', 'start')):
refuse('Missing owned process binding')
relationship = bound.get('relationship')
if not isinstance(relationship, dict):
refuse('Missing private display process relationship')
if relationship.get('mode') == 'descendant':
if relationship.get('descendant') is not True:
refuse('Native descendant relationship was not verified')
elif relationship.get('mode') == 'shared-listeners':
checks = relationship.get('checks')
if (not shared_relationship_valid(checks) or
checks['listener_inodes'] != [sock['inode'] for sock in sockets] or
bound['xwayland']['start'] < bound['gamescope']['start']):
refuse('Shared-listener process relationship was not verified')
else:
refuse('Unknown private display process relationship')
windows = report.get('owned_windows')
if not isinstance(windows, list) or not windows:
refuse('No verified game window records')
for window in windows:
identity = window.get('process', {})
if (type(window.get('window')) is not int or window['window'] <= 0
or type(window.get('server_pid')) is not int or type(identity.get('pid')) is not int
or identity.get('uid') != os.getuid() or type(identity.get('start')) is not int or identity['start'] <= 0
or type(window.get('pid')) is not int or window['pid'] <= 0
or window['pid'] != window.get('server_pid') or window['pid'] != identity.get('pid')):
refuse('Window lacks consistent native and server process ownership')
result['verified'] = True
except (OSError, ValueError, TypeError, AttributeError) as error:
result['error'] = str(error)
return result
def validate_input_observer(a):
if not getattr(a, 'observe_player_input', False):
return
if a.project == CONTROL or getattr(a, 'packed_core', None) or getattr(a, 'packed_addons', None):
refuse('Input observation requires loose Core or Chat, not packed input or vanilla control')
if a.settle < 50:
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
def input_observer_command(command, project, packed, nonce):
if project not in {'core', 'chat'} or packed is not None:
refuse('Input observation requires loose Core or Chat')
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
refuse('Input observer nonce must contain 1..32 ASCII letters/digits')
if any(value.startswith('-errOrdinaryInput') for value in command):
refuse('Input observer command already has observer options')
return command + ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
def input_observer_evidence(content, nonce):
"""Strictly summarize native sampler records; never infer input delivery."""
import math
errors, records, samples = [], [], []
prefix = 'ERR INPUT OBSERVE: '
number = r'-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=('+number+r') (.+)')
sample_pattern = re.compile(r'SAMPLE origin=(.+?) forward=('+number+r') input=(.+?) velocity=(.+?) inventory=(0|1|true|false)')
def vector_value(value):
if value.startswith('<') and value.endswith('>'):
value = value[1:-1]
if not re.fullmatch(number+r'(?:\s*,\s*|\s+)'+number+r'(?:\s*,\s*|\s+)'+number, value.strip()):
raise ValueError('invalid vector')
result = [float(x) for x in re.findall(number, value)]
if not all(math.isfinite(x) for x in result):
raise ValueError('nonfinite vector')
return result
lines = [line.split(prefix, 1)[1].strip() for line in content.splitlines() if prefix in line]
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
errors.append('invalid expected nonce')
if len(lines) > 460:
errors.append('observer record bound exceeded')
for line in lines[:460]:
match = header.fullmatch(line)
if not match:
errors.append('malformed observer record')
continue
actual, index, real, world, event = match.groups()
index, real, world = int(index), int(real), float(world)
if actual != nonce:
errors.append('observer nonce mismatch')
continue
if not math.isfinite(world) or world < 0 or index > 450 or real > 2147483647:
errors.append('observer sample/clock bound invalid')
continue
record = {'sample': index, 'real_ms': real, 'world_ms': world, 'event': event}
if records and (real < records[-1]['real_ms'] or world < records[-1]['world_ms'] or index < records[-1]['sample']):
errors.append('observer records reversed')
if event.startswith('SAMPLE '):
sample = sample_pattern.fullmatch(event)
if not sample:
errors.append('malformed observer sample')
continue
origin, forward, movement, velocity, opened = sample.groups()
try:
value = {**record, 'origin': vector_value(origin), 'forward': float(forward),
'input': vector_value(movement), 'velocity': vector_value(velocity),
'inventory': opened in {'1', 'true'}}
if not math.isfinite(value['forward']):
raise ValueError('nonfinite forward')
except ValueError:
errors.append('invalid observer sample values')
continue
if index != len(samples)+1:
errors.append('observer sample sequence is not contiguous')
samples.append(value)
elif event not in {'QUEUED', 'READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED',
'CAPTURE_REQUEST baseline', 'CAPTURE_REQUEST open', 'CAPTURE_REQUEST closed',
'FINISHED observation-bound', 'FINISHED sample-bound'} and not re.fullmatch(r'CANCELLED [a-z-]+', event):
errors.append('unknown observer event')
elif index != len(samples):
errors.append('observer transition sample does not match last sample')
records.append(record)
def matching(name):
return [r for r in records if r['event'] == name or r['event'].startswith(name+' ')]
queued, ready, finished, cancelled = (matching(name) for name in ('QUEUED', 'READY', 'FINISHED', 'CANCELLED'))
if len(queued) != 1 or not records or records[0] not in queued or records[0]['sample'] != 0:
errors.append('missing/duplicate/misordered QUEUED')
if len(ready) != 1:
errors.append('missing/duplicate READY')
if len(finished) != 1 or not records or records[-1] not in finished:
errors.append('missing/duplicate/misordered FINISHED')
if cancelled:
errors.append('observer cancelled')
if not samples:
errors.append('no native samples')
if queued:
start = queued[0]['real_ms']
if any(s['real_ms'] < start or s['real_ms']-start >= 45000 for s in samples):
errors.append('native sample outside real-time observation window')
if ready and (not samples or ready[0]['real_ms'] < samples[0]['real_ms']+2000):
errors.append('READY before stationary baseline interval')
if finished:
end = finished[0]
if end['event'] == 'FINISHED observation-bound' and end['real_ms']-start < 45000:
errors.append('FINISHED before real-time bound')
if end['event'] == 'FINISHED sample-bound' and len(samples) != 450:
errors.append('FINISHED before sample bound')
opens, closes = matching('INVENTORY_OPEN'), matching('INVENTORY_CLOSED')
if len(opens) > 1 or len(closes) > 1:
errors.append('duplicate inventory transition')
by_index = {s['sample']: s for s in samples}
for transition, expected in [(r, True) for r in opens] + [(r, False) for r in closes]:
sample = by_index.get(transition['sample'])
if not ready or records.index(transition) < records.index(ready[0]) or not sample or sample['inventory'] is not expected:
errors.append('inventory transition lacks matching post-READY sample')
if closes and (not opens or records.index(closes[0]) <= records.index(opens[0])):
errors.append('inventory close lacks prior observed open')
captures = matching('CAPTURE_REQUEST')
for suffix, transition in [('baseline', ready), ('open', opens), ('closed', closes)]:
requested = [r for r in captures if r['event'] == 'CAPTURE_REQUEST '+suffix]
if len(requested) > 1 or (requested and (not transition or records.index(requested[0]) <= records.index(transition[0]) or requested[0]['sample'] != transition[0]['sample'])):
errors.append('capture request lacks its unique transition')
return {'nonce': nonce, 'read_only': True, 'input_sent': False, 'sampler_complete': not errors,
'queued': len(queued) == 1, 'ready': len(ready) == 1, 'finished': len(finished) == 1,
'cancelled': bool(cancelled), 'sample_count': len(samples), 'record_count': len(lines),
'inventory_open_observed': len(opens) == 1, 'inventory_close_observed': len(closes) == 1,
'capture_requests': [r['event'].split()[-1] for r in captures],
'transitions': [r for r in records if not r['event'].startswith('SAMPLE ')],
'errors': list(dict.fromkeys(errors)),
'scope': 'native read-only sampler lifecycle only; no generated input, movement success, inventory usability, screenshot file verification, manual playtest or multiplayer claim'}
def input_observer_result(result, content, nonce):
evidence = input_observer_evidence(content, nonce)
if result == 'game-started' and not evidence['sampler_complete']:
result = 'observation-incomplete'
return result, evidence
def run(a):
validate_input_observer(a)
packed = packed_request(a)
if getattr(a, 'capture_prison_view', False) and (a.project == CONTROL or packed is not None):
refuse('Prison view capture requires loose successor source with its opt-in observer')
if getattr(a, 'inspect_headless_display', False) and (a.project == CONTROL or packed is not None):
refuse('Display ownership inspection currently requires loose Core or Chat')
steam, compat = a.steam.resolve(), a.compat.resolve()
profile = packed['session']/'profile' if packed else a.profile.resolve()
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
@@ -549,6 +818,12 @@ def run_locked(a, steam, compat, profile, packed=None):
if link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
refuse('Private profile addons must contain only the installed vanilla link')
run_id = uuid.uuid4().hex
observation_nonce = run_id if getattr(a, 'observe_player_input', False) else None
if observation_nonce:
command = input_observer_command(command, a.project, packed, observation_nonce)
display_context = None
if getattr(a, 'inspect_headless_display', False):
command, display_context = display_probe_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''))
silent_sink = 'erri_game_'+run_id
application_id = 'org.escapistsrereintroduced.game.'+run_id
env = os.environ.copy()
@@ -565,6 +840,7 @@ def run_locked(a, steam, compat, profile, packed=None):
result = 'timeout'
runner_error = None
cleanup_ok = True
cleanup_report = None
log_root = profile/'logs'
previous = set(log_root.glob('logs_*'))
launch_log = profile/('game-launch-'+run_id+'.log')
@@ -618,13 +894,12 @@ def run_locked(a, steam, compat, profile, packed=None):
runner_error = str(error)
finally:
if child is not None:
try:
cleanup(child, proton, env)
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
cleanup_ok = False
cleanup_report = cleanup_owned_run(child,proton,env,compat)
cleanup_ok = cleanup_report['cleanup_confirmed']
if not cleanup_ok:
result = 'cleanup-failed'
runner_error = str(error)
cleanup_error = '; '.join(cleanup_report['errors'])
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
if sink_module is not None:
try:
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
@@ -650,10 +925,20 @@ def run_locked(a, steam, compat, profile, packed=None):
result = 'vanilla-control-started'
if packed and result == 'game-started':
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
display_result = None
startup_result = result
if display_context:
display_result = display_probe_result(display_context, session)
if not display_result['verified'] and result == 'game-started':
result = 'display-probe-incomplete'
observation_result = None
if observation_nonce:
result, observation_result = input_observer_result(result, content, observation_nonce)
final['input_observation'] = observation_result
source_bindings = {} if packed else bindings(captured)
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
'session': str(session) if session else None, 'project': a.project,
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
'cleanup_confirmed': cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error': runner_error,
'failure_grace_seconds': a.failure_grace,
'failure_observed_during_polling': monitor.first_failure_at is not None,
'first_failure_elapsed_seconds': (monitor.first_failure_at - monitor.started) if monitor.first_failure_at is not None else None,
@@ -662,6 +947,11 @@ def run_locked(a, steam, compat, profile, packed=None):
'control_only': a.project == CONTROL,
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
if observation_result is not None:
summary['input_observation'] = observation_result
if display_result is not None:
summary['display_ownership'] = display_result
summary['startup_result_before_display_check'] = startup_result
if packed:
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
final['packed_consumption_verified'] = summary['packed_consumption_verified']
@@ -686,7 +976,9 @@ def main():
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
parser.add_argument('--inspect-headless-display', action='store_true', help='Read-only ownership inspection within the hidden display; no input sent, loose Core/Chat only')
parser.add_argument('--capture-prison-view', action='store_true', help='Opt-in single genuine local prison screenshot; loose successor source only, no camera or input changes')
parser.add_argument('--observe-player-input', action='store_true', help='Read-only native local-input sampler; loose Core/Chat only, requires --settle >=50; sends no input')
parser.add_argument('--timeout', type=int, default=180)
parser.add_argument('--settle', type=int, default=15)
parser.add_argument('--failure-grace', type=int, default=0, help='Keep a failed run alive for diagnostic logs only, at most 0..30 seconds within the original timeout')
+27 -20
View File
@@ -6,12 +6,13 @@ No desktop rules or existing Wine processes are changed. This is not a dedicated
import argparse, fcntl, json, os, shutil, signal, subprocess, time
from pathlib import Path
from native_provenance import snapshot, persist, bindings, seal_run
from headless_game import assert_prefix_idle, cleanup_owned_run
ROOT = Path(__file__).resolve().parents[1]
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
p.add_argument('--smoke', choices=['native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--smoke', choices=['aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
@@ -27,7 +28,7 @@ if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.ti
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
if a.smoke in {'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
if a.smoke in {'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds')
if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover')
if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
@@ -47,6 +48,9 @@ if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
prefix_lock = (compat/'.erri-workbench.lock').open('a')
try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix')
# The lock must be held before the preflight; never repair a busy prefix here.
try: assert_prefix_idle(compat)
except (OSError,ValueError) as error: p.error(str(error))
if profile.is_relative_to(ROOT) or profile.is_relative_to(steam):
p.error('--profile must be outside source and Steam directories')
if not shutil.which('gamescope') or not shutil.which('pactl'): p.error('gamescope and pactl are required')
@@ -104,7 +108,9 @@ def silence_test_streams():
pid = props.get('application.process.id', '')
if not owned and pid.isdigit():
try:
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
target = str(compat/'pfx').encode()
values = (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
owned = any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in values)
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
if not owned: continue
index = str(stream['index'])
@@ -122,9 +128,13 @@ started = time.time()
log_root = profile/'logs'
previous = set(log_root.glob('logs_*')) if log_root.exists() else set()
result, session, complete_at = 'timeout', None, None
runner_error = None
cleanup_ok = True
cleanup_report = None
child = None
with (profile/'headless-launch.log').open('w') as output:
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
try:
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
while time.time()-started < a.timeout:
silence_test_streams()
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
@@ -144,23 +154,20 @@ with (profile/'headless-launch.log').open('w') as output:
time.sleep(1)
except KeyboardInterrupt:
result = 'interrupted'
except (OSError,ValueError,subprocess.SubprocessError) as error:
result = 'runner-failed'; runner_error = str(error)
finally:
# Stop Wine in this disposable prefix only. Proton's reaper may create a
# separate process group, so killing Gamescope alone can leave Wine alive.
wine_server = proton.parent/'files/bin/wineserver'
subprocess.run([str(wine_server), '-k'], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
# Only this fresh process group belongs to the runner and disposable prefix.
try: os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError: pass
try: child.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL); child.wait()
# Gamescope's reaper can outlive its parent and ignores SIGTERM.
try: os.killpg(child.pid, signal.SIGKILL)
except ProcessLookupError: pass
stopped_evidence = seal_run(ROOT, session, captured_sources) if session else {}
if session and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
result_before_cleanup = result
if child is not None:
cleanup_report = cleanup_owned_run(child,proton,env,compat)
cleanup_ok = cleanup_report['cleanup_confirmed']
if not cleanup_ok:
result = 'cleanup-failed'
cleanup_error = '; '.join(cleanup_report['errors'])
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
stopped_evidence = seal_run(ROOT, session, captured_sources) if cleanup_ok and session else {}
if stopped_evidence and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
if session:
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log')
+301
View File
@@ -0,0 +1,301 @@
#!/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())