Files
EscapistsReintroduced/tests/test_private_prefix_cleanup.py
T
jakub a951a96edc Fix faction ammunition resupply and settings recovery
Wait for both convoy cargo phases and use actual magazine stock. Resolve empty primary weapon ammunition from muzzle metadata and recover cleanly from failed initial settings saves.

Update native fixtures, scoped cleanup guards, packaging evidence and validation documentation. Make reviewed fixture tests checkout-relative. All repository checks pass against the isolated staged tree; native resupply completion and multiplayer qualification remain outstanding.
2026-09-30 14:34:46 +02:00

177 lines
12 KiB
Python

"""Fake process/pidfd tests only. Never invoke native cleanup or send a signal."""
import dataclasses
import importlib.util
import signal
import sys
from pathlib import Path
import unittest
from unittest.mock import patch, MagicMock
ROOT=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('private_prefix_cleanup',ROOT/'tools/private_prefix_cleanup.py')
m=importlib.util.module_from_spec(spec);sys.modules[spec.name]=m;spec.loader.exec_module(m)
UID=1000
EXE='/installed/Proton/files/lib/wine/i386-unix/wine64-preloader'
INODE=(2,44)
PARENT=m.Process(1670,UID,1,100,'S','systemd',m.SYSTEMD_ARGV,None,None)
def proc(pid=10,**changes):
p=m.Process(pid,UID,PARENT.pid,1000+pid,'S','winedevice.exe',m.DEVICE_ARGV,EXE,INODE,m.PREFIX)
return dataclasses.replace(p,**changes)
class Inspector:
def __init__(self,processes=()):self.processes={p.pid:p for p in processes};self.parent=PARENT;self.reads=0
def devices(self):
self.reads+=1;result=[]
for p in self.processes.values():
if p.state in {'Z','X'} or m.prefix_kind(p.prefix)=='other':continue
if m.prefix_kind(p.prefix)=='alias':raise m.CleanupRefused('ambiguous private-prefix spelling')
result.append(m.prove_device(p,self.parent,UID,EXE,INODE))
return result
class FakePidfds:
def __init__(self,inspector,term_exits=True,kill_exits=True):
self.inspector=inspector;self.term_exits=term_exits;self.kill_exits=kill_exits;self.opened={};self.closed=[];self.sent=[];self.waits=[];self.on_open=None;self.on_send=None
def open(self,pid):
if pid not in self.inspector.processes:raise ProcessLookupError()
fd=pid+1000;self.opened[fd]=self.inspector.processes[pid]
if self.on_open:self.on_open(pid)
return fd
def close(self,fd):self.closed.append(fd)
def send(self,fd,sig):
original=self.opened[fd];self.sent.append((original.pid,sig))
if self.on_send:self.on_send(original.pid,sig)
if (sig==signal.SIGTERM and self.term_exits) or (sig==signal.SIGKILL and self.kill_exits):
current=self.inspector.processes.get(original.pid)
if current and current.start==original.start:self.inspector.processes.pop(original.pid)
def wait(self,handles,timeout):
self.waits.append(timeout);finished=set()
for pid,fd in handles.items():
current=self.inspector.processes.get(pid);original=self.opened[fd]
if not current or current.start!=original.start or current.state in {'Z','X'}:finished.add(pid)
return finished
class PrivatePrefixCleanup(unittest.TestCase):
def test_exact_prefix_and_observed_device(self):
self.assertEqual(m.DEVICE_ARGV[0].hex(),'433a5c77696e646f77735c73797374656d33325c77696e656465766963652e657865')
for suffix in (b'',b'/',b'//'):
p=proc(prefix=m.PREFIX+suffix);self.assertEqual(m.prefix_kind(p.prefix),'exact');self.assertEqual(m.prove_device(p,PARENT,UID,EXE,INODE).process.pid,10)
for other in (None,b'/other/pfx',m.PREFIX+b'-sibling',b'/tmp/another/pfx'):
self.assertEqual(m.prefix_kind(other),'other')
self.assertEqual(m.prefix_kind(m.PREFIX+b'/../pfx'),'alias')
def test_identity_and_parent_refusals(self):
mutations=[{'uid':UID+1},{'state':'Z'},{'argv':(*m.DEVICE_ARGV,b'extra')},{'argv':(b'Z:\\winedevice.exe',)},{'comm':'winedbg.exe'},{'exe':'/untrusted/wine64-preloader'},{'executable_id':(2,45)},{'prefix':b'/other/pfx'}]
for mutation in mutations:
with self.subTest(mutation=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(**mutation),PARENT,UID,EXE,INODE)
for mutation in ({'uid':UID+1},{'pid':3},{'ppid':2},{'comm':'bash'},{'argv':(b'/usr/lib/systemd/systemd',)},{'exe':'/tmp/systemd'},{'state':'Z'}):
with self.subTest(parent=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(),dataclasses.replace(PARENT,**mutation),UID,EXE,INODE)
self.assertTrue(m.prove_device(proc(),dataclasses.replace(PARENT,exe='/usr/lib/systemd/systemd'),UID,EXE,INODE))
def test_user_systemd_reexec_parent_and_unrelated_metadata(self):
for descriptor in (b'0',b'10',b'2147483647'):
parent=dataclasses.replace(PARENT,argv=(*m.SYSTEMD_ARGV,b'--deserialize='+descriptor))
self.assertTrue(m.native_metadata_proves_unrelated(parent))
self.assertEqual(m.prove_device(proc(),parent,UID,EXE,INODE).parent,parent)
i=Inspector([proc()]);i.parent=parent;f=FakePidfds(i)
self.assertTrue(m._cleanup(i,f)['cleared']);self.assertEqual(f.sent,[(10,signal.SIGTERM)])
invalid=[(*m.SYSTEMD_ARGV,b'--deserialize='+value) for value in
(b'',b'-1',b'+10',b'10 ',b' 10',b'10x',b'1.0',b'2147483648',b'99999999999')]
invalid.extend([(*m.SYSTEMD_ARGV,b'--deserialize',b'10'),
(*m.SYSTEMD_ARGV,b'--deserialize=10',b'extra'),
(*m.SYSTEMD_ARGV,b'--deserialize=10',b'proton'),
(*m.SYSTEMD_ARGV,b'--unknown=10'),
(b'/tmp/systemd',b'--user',b'--deserialize=10')])
for argv in invalid:
parent=dataclasses.replace(PARENT,argv=argv)
with self.subTest(argv=argv):
self.assertFalse(m.user_systemd_argv(argv))
self.assertFalse(m.native_metadata_proves_unrelated(parent))
with self.assertRaises(m.CleanupRefused):m.prove_device(proc(),parent,UID,EXE,INODE)
parent=dataclasses.replace(PARENT,argv=(*m.SYSTEMD_ARGV,b'--deserialize=10'))
for mutation in ({'uid':UID+1},{'pid':3},{'ppid':2},{'comm':'bash'},
{'exe':'/tmp/systemd'},{'state':'Z'}):
with self.subTest(mutation=mutation),self.assertRaises(m.CleanupRefused):
m.prove_device(proc(),dataclasses.replace(parent,**mutation),UID,EXE,INODE)
def test_reexec_parent_identity_change_before_signal_refused(self):
for mutation in ({'argv':(*m.SYSTEMD_ARGV,b'--deserialize=11')},{'start':999}):
i=Inspector([proc()]);i.parent=dataclasses.replace(PARENT,argv=(*m.SYSTEMD_ARGV,b'--deserialize=10'));f=FakePidfds(i)
f.on_open=lambda pid:i.__setattr__('parent',dataclasses.replace(i.parent,**mutation))
with self.subTest(mutation=mutation),self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[]);self.assertEqual(f.closed,[1010])
def test_clean_term_ignores_other_prefix(self):
i=Inspector([proc(10),proc(11,prefix=m.PREFIX+b'/'),proc(12,prefix=b'/other/pfx')]);f=FakePidfds(i)
result=m._cleanup(i,f);self.assertTrue(result['cleared']);self.assertEqual(result['term_sent'],[10,11]);self.assertEqual(result['kill_sent'],[])
self.assertEqual(set(i.processes),{12});self.assertEqual(set(f.closed),set(f.opened));self.assertEqual(max(f.waits),3.0)
self.assertNotIn('argv',str(result));self.assertNotIn('environment',str(result))
def test_other_live_private_process_refuses_before_open_or_signal(self):
for other in (proc(11,comm='ArmaReforger',argv=(b'ArmaReforger.exe',)),proc(11,ppid=42),proc(11,prefix=m.PREFIX+b'/../pfx')):
i=Inspector([proc(),other]);f=FakePidfds(i)
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
def test_pid_reuse_after_open_refuses(self):
i=Inspector([proc()]);f=FakePidfds(i)
f.on_open=lambda pid:i.processes.update({pid:proc(pid,start=8888)})
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[]);self.assertEqual(f.closed,[1010])
def test_parent_reuse_or_role_change_refuses(self):
for change in ('start','role','prefix','exe'):
i=Inspector([proc()]);f=FakePidfds(i)
def mutate(pid):
if change=='start':i.parent=dataclasses.replace(PARENT,start=999)
elif change=='role':i.processes[pid]=proc(pid,argv=(b'winedbg.exe',))
elif change=='prefix':i.processes[pid]=proc(pid,prefix=b'/other/pfx')
else:i.processes[pid]=proc(pid,executable_id=(9,9))
f.on_open=mutate
with self.subTest(change=change),self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[])
def test_bounded_kill_uses_same_pinned_handles(self):
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i,term_exits=False)
result=m._cleanup(i,f)
self.assertEqual(f.sent,[(10,signal.SIGTERM),(11,signal.SIGTERM),(10,signal.SIGKILL),(11,signal.SIGKILL)])
self.assertTrue(result['cleared']);self.assertEqual(result['kill_sent'],[10,11]);self.assertEqual(f.waits.count(3.0),1);self.assertEqual(f.waits.count(1.0),1)
def test_new_active_process_after_partial_term_preserved(self):
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i)
def new_process(pid,sig):
if pid==10:i.processes[99]=proc(99,comm='active-game',argv=(b'game.exe',))
f.on_send=new_process
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
self.assertEqual(f.sent,[(10,signal.SIGTERM)]);self.assertEqual(failure.exception.report['term_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertIn(11,i.processes);self.assertIn(99,i.processes)
def test_partial_cleanup_failure_has_no_broad_fallback(self):
i=Inspector([proc()]);f=FakePidfds(i,term_exits=False,kill_exits=False)
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
self.assertEqual(failure.exception.report['kill_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertEqual(f.closed,[1010]);self.assertEqual(len(f.sent),2)
def test_new_adopted_helper_not_in_snapshot_refused(self):
i=Inspector([proc()]);f=FakePidfds(i);f.on_open=lambda pid:i.processes.update({11:proc(11)})
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[])
def test_production_scope_rejects_other_prefix_before_backend(self):
with patch.object(m,'Pidfds',side_effect=AssertionError('No signal backend permitted')):
for other in ('/tmp/other','/home/user/.wine','/tmp/erri-headless-compat/../other'):
with self.subTest(other=other),self.assertRaises(m.CleanupRefused):m.cleanup_private_prefix(other,'/installed/proton')
def test_exited_zombies_are_not_signalled(self):
i=Inspector([proc(state='Z')]);f=FakePidfds(i);r=m._cleanup(i,f);self.assertTrue(r['cleared']);self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
def test_metadata_permissions_do_not_hide_runtime_candidates(self):
self.assertTrue(m.native_metadata_proves_unrelated(PARENT))
self.assertFalse(m.native_metadata_proves_unrelated(proc(exe=None,executable_id=None)))
self.assertFalse(m.native_metadata_proves_unrelated(proc(comm='python',argv=(b'/usr/bin/python',b'/runtime/proton'),exe='/usr/bin/python')))
def test_observed_nondumpable_native_services_are_unrelated(self):
agent=proc(comm='ssh-agent',argv=(b'/usr/bin/ssh-agent',b'-D',b'-a',f'/run/user/{UID}/ssh-agent.socket'.encode()),exe=None,executable_id=None)
self.assertTrue(m.native_metadata_proves_unrelated(agent))
for comm,argv in [('kwin_wayland',(b'/usr/bin/kwin_wayland',)),('polkit-kde-auth',(b'/usr/lib/polkit-kde-authentication-agent-1',)),('apache2',(b'apache2',b'-DFOREGROUND'))]:
self.assertTrue(m.native_metadata_proves_unrelated(dataclasses.replace(agent,comm=comm,argv=argv)))
for mutation in ({'comm':'winedevice.exe'},{'argv':agent.argv+(b'proton',)},{'argv':(b'/usr/bin/something-else',)},{'argv':(b'ssh-agent',)},{'comm':'apache2','argv':(b'apache2',b'-unknown')},{'comm':'wine64-preloade','argv':(b'/runtime/wine64-preloader',)}):
self.assertFalse(m.native_metadata_proves_unrelated(dataclasses.replace(agent,**mutation)))
def test_unreadable_scdaemon_skipped_but_runtime_and_near_matches_refused(self):
entry=MagicMock();entry.name='10';entry.stat.return_value.st_uid=UID
entry.__truediv__.return_value.read_bytes.side_effect=PermissionError()
root=MagicMock();root.iterdir.side_effect=lambda:iter([entry])
inspector=m.ProcFS(EXE,INODE,root=root,uid=UID,self_pid=99)
native=proc(comm='scdaemon',argv=(b'scdaemon',b'--multi-server'),exe=None,executable_id=None)
with patch.object(inspector,'metadata',return_value=native):
self.assertEqual(inspector.devices(),[])
for candidate in [proc(exe=None,executable_id=None),
dataclasses.replace(native,comm='wine64-preloade'),
dataclasses.replace(native,argv=native.argv+(b'proton',)),
dataclasses.replace(native,argv=(b'scdaemon',b'--unknown'))]:
with self.subTest(candidate=candidate),patch.object(inspector,'metadata',return_value=candidate),self.assertRaises(m.CleanupRefused):
inspector.devices()
if __name__=='__main__':unittest.main()