Verify bounded ordinary-input approach to the prison gate
This commit is contained in:
@@ -479,4 +479,274 @@ class PointerTests(unittest.TestCase):
|
||||
with self.assertRaises(proof.Refused):mouse.move(dx,dy,guard)
|
||||
|
||||
|
||||
|
||||
class ApproachTests(unittest.TestCase):
|
||||
def test_explicit_mode_requires_look_and_matching_native_gate_nonce(self):
|
||||
argv=['--enable-private-keyboard-proof','--prefix','/tmp/pfx','--game-executable','/tmp/game',
|
||||
'--profile','/tmp/profile','--report','/tmp/report','--host-display',':0','--nonce','N']
|
||||
with self.assertRaises(SystemExit),patch('sys.stderr'):
|
||||
proof.arguments(argv+['--enable-private-approach-proof'])
|
||||
self.assertFalse(proof.arguments(argv).enable_private_approach_proof)
|
||||
args=proof.arguments(argv+['--enable-private-look-proof','--enable-private-approach-proof'])
|
||||
native={'args':['-errOrdinaryInputObserve','N','-errOrdinaryLookObserve','N','-errOrdinaryGateObserve','N',
|
||||
'-errOrdinaryScenario','core','-profile','Z:/tmp/profile']}
|
||||
owner={'pid':123,'process':{'pid':123}}
|
||||
with patch.object(proof.display,'process',return_value=native),patch.object(proof.display,'identity',return_value=owner['process']):
|
||||
proof.game_arguments(owner,args)
|
||||
native['args'][5]='foreign'
|
||||
with self.assertRaisesRegex(proof.Refused,'gate observer nonce'):proof.game_arguments(owner,args)
|
||||
def test_variant_runs_look_then_route_without_default_w_tab_cycle(self):
|
||||
clock=Clock();observer=FakeObserver();observer.enable_approach=True;observer.ready_at=0
|
||||
guard=FakeGuard();keyboard=FakeKeyboard(observer);report={};order=[];notifications=[]
|
||||
def look(*args):order.append('look');report['look_proof']={'completed':True};clock.sleep(.2)
|
||||
def route(g,k,o,m,r,c,s):
|
||||
self.assertEqual(order,['look']);order.append('approach')
|
||||
k.send('W',True);k.send('W',False)
|
||||
r['approach_proof']={'completed':True,'paired_release_errors':[],'unconfirmed_held_key':None}
|
||||
with patch.object(proof,'perform_look',side_effect=look),patch.object(proof,'run_approach',side_effect=route),patch.object(proof,'notify',side_effect=notifications.append):
|
||||
proof.sequence(guard,keyboard,observer,report,clock,clock.sleep,mouse=object(),approach=True)
|
||||
self.assertEqual(order,['look','approach']);self.assertEqual(keyboard.events,[('W',True),('W',False)])
|
||||
self.assertTrue(report['sequence_completed']);self.assertTrue(report['all_attempted_presses_released'])
|
||||
self.assertEqual(notifications[0],{'kind':'approach-pending','deadline_monotonic':35})
|
||||
self.assertEqual(notifications[-1],{'kind':'approach-finished','successful':True})
|
||||
def test_failed_look_never_calls_route_and_keeps_uncertain_route_release(self):
|
||||
clock=Clock();o=FakeObserver();o.enable_approach=True;o.ready_at=0;k=FakeKeyboard(o);r={}
|
||||
with patch.object(proof,'perform_look',side_effect=proof.Refused('look refusal')),patch.object(proof,'run_approach') as route:
|
||||
with self.assertRaisesRegex(proof.Refused,'look refusal'):
|
||||
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
|
||||
route.assert_not_called();self.assertEqual(k.events,[])
|
||||
def failed(*args):
|
||||
r['approach_proof']={'completed':False,'unconfirmed_held_key':'W','paired_release_errors':['ownership lost']}
|
||||
raise proof.Refused('route refusal')
|
||||
with patch.object(proof,'perform_look'),patch.object(proof,'run_approach',side_effect=failed):
|
||||
with self.assertRaisesRegex(proof.Refused,'route refusal'):
|
||||
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
|
||||
self.assertEqual(r['unconfirmed_held_key'],'W');self.assertFalse(r['all_attempted_presses_released'])
|
||||
self.assertFalse(r['sequence_completed']);self.assertEqual(r['paired_release_errors'],['ownership lost'])
|
||||
def test_original_ready_deadline_not_renewed_after_calibration(self):
|
||||
clock=Clock();clock.now=34;o=FakeObserver();o.enable_approach=True;o.ready_at=0;k=FakeKeyboard(o);r={}
|
||||
def route(*args):clock.sleep(2)
|
||||
with patch.object(proof,'perform_look'),patch.object(proof,'run_approach',side_effect=route):
|
||||
with self.assertRaisesRegex(proof.Refused,'Original approach deadline'):
|
||||
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
|
||||
self.assertFalse(r['sequence_completed']);self.assertEqual(k.events,[])
|
||||
def test_w_only_adapter_rejects_other_keys_and_expiry_but_allows_release(self):
|
||||
clock=Clock();o=FakeObserver();k=FakeKeyboard(o);r={'events':[]}
|
||||
adapter=proof.ApproachKeyboard(k,r,clock,35)
|
||||
for key,down in [('F',True),('Tab',True),('W',1)]:
|
||||
with self.assertRaises(proof.Refused):adapter.send(key,down)
|
||||
adapter.send('W',True);clock.now=35
|
||||
with self.assertRaisesRegex(proof.Refused,'deadline'):adapter.send('W',True)
|
||||
adapter.send('W',False)
|
||||
self.assertEqual(k.events,[('W',True),('W',False)]);self.assertTrue(r['input_sent'])
|
||||
def test_relative_approach_requires_explicit_capability_and_checks_pointer_and_deadline(self):
|
||||
class Fn:
|
||||
def __init__(self,fn):self.fn=fn
|
||||
def __call__(self,*args):return self.fn(*args)
|
||||
events=[];xt=SimpleNamespace(XTestFakeRelativeMotionEvent=Fn(lambda *a:events.append(a) or 1))
|
||||
x=SimpleNamespace(XQueryPointer=Fn(lambda *a:1),XFlush=lambda d:None)
|
||||
keyboard=SimpleNamespace(xt=xt,require_idle=lambda:None);query=SimpleNamespace(x=x,d=123)
|
||||
mouse=proof.RelativeMouse(query,keyboard,{'window':100,'pid':9});clock=Clock();checks=[]
|
||||
mouse.validate_pointer=lambda *a:checks.append(a);guard=SimpleNamespace(check=lambda:{'verified':True})
|
||||
with self.assertRaises(proof.Refused):mouse.approach_move(1,0,guard,deadline=35,clock=clock)
|
||||
mouse.enable_approach=True
|
||||
for dx,dy in [(1,0),(-128,0),(128,0)]:mouse.approach_move(dx,dy,guard,deadline=35,clock=clock)
|
||||
self.assertEqual(len(events),3);self.assertEqual(len(checks),6)
|
||||
for dx,dy in [(0,0),(129,0),(-129,0),(True,0),(1,1),(1,False)]:
|
||||
with self.assertRaises(proof.Refused):mouse.approach_move(dx,dy,guard,deadline=35,clock=clock)
|
||||
def delayed(*args):clock.now=35
|
||||
mouse.validate_pointer=delayed
|
||||
with self.assertRaisesRegex(proof.Refused,'deadline'):mouse.approach_move(1,0,guard,deadline=35,clock=clock)
|
||||
self.assertEqual(len(events),3)
|
||||
def test_raw_join_retains_partial_pair_and_every_intermediate_snapshot(self):
|
||||
from test_ordinary_gate_evidence import fixture,NONCE
|
||||
lines=[s for s in fixture().splitlines() if 'CANCELLED' not in s]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('')
|
||||
clock=Clock();clock.now=10
|
||||
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
|
||||
try:
|
||||
# Split after SAMPLE but before GEOMETRY: pair29 is newest complete.
|
||||
with path.open('a') as out:out.write('\n'.join(lines[:-1])+'\n')
|
||||
first=observer.approach_snapshots();self.assertEqual([s['sample'] for s in first],[29])
|
||||
self.assertEqual(first[0]['geometry']['gate'],(6090.,211.344,7154.))
|
||||
clock.sleep(.1)
|
||||
with path.open('a') as out:out.write(lines[-1]+'\n')
|
||||
following=observer.approach_snapshots();self.assertEqual([s['sample'] for s in following],[30])
|
||||
received=following[0]['received_monotonic'];clock.sleep(.1)
|
||||
repeated=observer.approach_snapshots();self.assertEqual(repeated,following)
|
||||
self.assertEqual(repeated[0]['received_monotonic'],received)
|
||||
clock.sleep(.5)
|
||||
with self.assertRaisesRegex(proof.Refused,'stale'):observer.approach_snapshots()
|
||||
finally:observer.close()
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('');clock=Clock()
|
||||
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
|
||||
try:
|
||||
pivot=next(i for i,s in enumerate(lines) if 'sample=27 ' in s)
|
||||
with path.open('a') as out:out.write('\n'.join(lines[:pivot])+'\n')
|
||||
self.assertEqual(observer.approach_snapshots()[-1]['sample'],26)
|
||||
clock.sleep(.1)
|
||||
with path.open('a') as out:out.write('\n'.join(lines[pivot:])+'\n')
|
||||
self.assertEqual([s['sample'] for s in observer.approach_snapshots()],[27,28,29,30])
|
||||
finally:observer.close()
|
||||
def test_raw_join_refuses_gate_cancel_foreign_nonce_and_changed_geometry(self):
|
||||
from test_ordinary_gate_evidence import fixture,NONCE
|
||||
clean='\n'.join(s for s in fixture().splitlines() if 'CANCELLED' not in s)+'\n'
|
||||
for change in ('cancel','foreign','geometry','clock','action'):
|
||||
with self.subTest(change=change),tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('')
|
||||
observer=proof.Observer(path,NONCE,clock=lambda:10,enable_look=True,enable_approach=True)
|
||||
try:
|
||||
raw=clean
|
||||
if change=='cancel':raw+='ERR GATE OBSERVE: run='+NONCE+' sample=31 real=4410 CANCELLED native-player-or-prison-context-lost\n'
|
||||
if change=='foreign':raw=raw.replace('ERR GATE OBSERVE: run='+NONCE,'ERR GATE OBSERVE: run=foreign')
|
||||
if change=='geometry':raw=raw.replace('sample=30 real=4300 GEOMETRY body=', 'sample=30 real=4300 GEOMETRY body=').replace('sample=30 real=4300 GEOMETRY body=<6088.5,211.389,7158.5> context=<6092.77', 'sample=30 real=4300 GEOMETRY body=<6088.5,211.389,7158.5> context=<6093.77')
|
||||
if change=='action':raw=raw.replace('duration=4 pressed=0','duration=4 pressed=1')
|
||||
if change=='clock':raw=raw.replace('sample=30 real=4300 GEOMETRY','sample=30 real=4301 GEOMETRY')
|
||||
with path.open('a') as out:out.write(raw)
|
||||
with self.assertRaises(ValueError):observer.approach_snapshots()
|
||||
finally:observer.close()
|
||||
def test_adapter_requires_independent_look_acceptance_before_module(self):
|
||||
observer=SimpleNamespace(approach_data=lambda:('raw',{}),nonce='N');report={'look_proof':{}}
|
||||
with patch('ordinary_look_evidence.validate_look_evidence',return_value={'verified':False,'errors':['native mismatch']}),patch('prison_approach_proof.perform_approach') as run:
|
||||
with self.assertRaisesRegex(proof.Refused,'calibration refused'):
|
||||
proof.run_approach(None,None,observer,None,report,lambda:0,lambda s:None)
|
||||
run.assert_not_called()
|
||||
def test_real_calibration_adapter_preserves_clocks_caps_and_completion_owner(self):
|
||||
import math
|
||||
from test_ordinary_gate_evidence import fixture,NONCE
|
||||
raw='\n'.join(s for s in fixture().splitlines() if 'CANCELLED' not in s)+'\n'
|
||||
angle=math.radians(2)
|
||||
raw='\n'.join(line.replace('direction=<0,0,1>',f'direction=<{math.sin(angle)},0,{math.cos(angle)}>') if 'ERR LOOK OBSERVE:' in line and 'sample=22 ' in line else line for line in raw.splitlines())+'\n'
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('');clock=Clock();clock.now=10
|
||||
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
|
||||
try:
|
||||
with path.open('a') as out:out.write(raw)
|
||||
observer.fresh();clock.sleep(.4)
|
||||
look={'requested':True,'completed':True,'started_monotonic':10.,'finished_monotonic':10.4,
|
||||
'baseline_samples':[20,21],'response_sample':22,'return_sample':23,
|
||||
'motions':[{'dx':24,'dy':0,'monotonic':10.1,'after_sample':21,'after_real_ms':3310},
|
||||
{'dx':-24,'dy':0,'monotonic':10.3,'after_sample':22,'after_real_ms':3420}]}
|
||||
report={'look_proof':look,'events':[]};keyboard=FakeKeyboard(observer);notifications=[];motions=[]
|
||||
mouse=SimpleNamespace(approach_move=lambda *a,**k:motions.append((a,k)))
|
||||
def module(guard,k,m,observe,geometry,calibration,r,**kwargs):
|
||||
self.assertEqual(calibration['ready_monotonic'],10)
|
||||
self.assertEqual(calibration['ready_real_ms'],3091)
|
||||
self.assertEqual(calibration['observer_start_real_ms'],1000)
|
||||
self.assertEqual(kwargs['key_state_busy'],proof.KeyStateBusy)
|
||||
self.assertEqual(observe()[-1]['geometry'],geometry)
|
||||
self.assertEqual(geometry['nonce'],NONCE)
|
||||
with self.assertRaises(proof.Refused):k.send('F',True)
|
||||
k.send('W',True);k.send('W',False);m.move(12,0,guard)
|
||||
r['approach_proof']={'completed':True}
|
||||
kwargs['notify']({'kind':'approach-pending','deadline_monotonic':45})
|
||||
kwargs['notify']({'kind':'approach-finished','successful':True})
|
||||
with patch('prison_approach_proof.perform_approach',side_effect=module),patch.object(proof,'notify',side_effect=notifications.append):
|
||||
proof.run_approach(FakeGuard(),keyboard,observer,mouse,report,clock,clock.sleep)
|
||||
self.assertEqual([e['key'] for e in report['events']],['W','W'])
|
||||
self.assertEqual(motions[0][1]['deadline'],45)
|
||||
self.assertEqual(notifications,[{'kind':'approach-pending','deadline_monotonic':45}])
|
||||
self.assertTrue(report['input_sent'])
|
||||
finally:observer.close()
|
||||
|
||||
def test_actual_main_metadata_allows_exactly_one_module_attempt(self):
|
||||
import copy
|
||||
from test_prison_approach_proof import Scene
|
||||
notices=[]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
args=SimpleNamespace(enable_private_look_proof=True,enable_private_approach_proof=True,nonce='test',
|
||||
report=Path(folder)/'proof.json',console=Path(folder)/'console.log',prefix=Path(folder)/'pfx',
|
||||
profile=Path(folder),game_executable=Path(folder)/'game',host_display=':0')
|
||||
with patch.object(proof,'arguments',return_value=args),patch.object(proof,'wrapper_command',return_value=[]),patch.object(proof,'console_path'),patch.object(proof.display,'validate_paths'),patch.object(proof.display,'binding',return_value={'display':':2'}),patch.object(proof,'Observer',side_effect=OSError('metadata-only fixture stop')),patch.object(proof.signal,'signal'),patch.object(proof,'notify',side_effect=lambda event:notices.append(copy.deepcopy(event))):
|
||||
self.assertEqual(proof.main([]),1)
|
||||
metadata=next(event['metadata'] for event in notices if event['kind']=='report')
|
||||
self.assertTrue(metadata['approach_requested']);self.assertNotIn('approach_proof',metadata)
|
||||
self.assertEqual(set(metadata['approach_module_sha256']),set(proof.APPROACH_MODULES))
|
||||
scene=Scene();self.assertTrue(scene.run(report=metadata)['completed'])
|
||||
with self.assertRaisesRegex(ValueError,'already exists'):scene.run(report=metadata)
|
||||
|
||||
def test_final_serialization_compacts_success_and_failure_without_mutating_live_samples(self):
|
||||
import copy
|
||||
sample={'sample':42,'real_ms':5200,'received_monotonic':10.2,'origin':[1,2,3],'geometry':{'large':'pose'}}
|
||||
for completed in (True,False):
|
||||
report={'approach_requested':True,'approach_proof':{'completed':completed,'samples':[copy.deepcopy(sample)],'error':'kept'}}
|
||||
encoded=proof.report_for_write(report)
|
||||
self.assertEqual(encoded['approach_proof']['samples'],[{'sample':42,'real_ms':5200,'received_monotonic':10.2}])
|
||||
self.assertEqual(report['approach_proof']['samples'],[sample])
|
||||
self.assertEqual(encoded['approach_proof']['error'],'kept')
|
||||
ordinary={'approach_requested':False,'events':[]}
|
||||
self.assertIs(proof.report_for_write(ordinary),ordinary)
|
||||
def test_exact_module_hashes_are_filename_bound_and_missing_module_refuses(self):
|
||||
import hashlib
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder)
|
||||
for name in proof.APPROACH_MODULES:(root/name).write_text(name)
|
||||
with patch.object(proof,'__file__',str(root/'headless_input_proof.py')):
|
||||
values=proof.approach_module_hashes()
|
||||
self.assertEqual(set(values),set(proof.APPROACH_MODULES))
|
||||
self.assertEqual(values['prison_approach_proof.py'],hashlib.sha256(b'prison_approach_proof.py').hexdigest())
|
||||
(root/'prison_approach_proof.py').unlink()
|
||||
with self.assertRaises(OSError):proof.approach_module_hashes()
|
||||
def test_max_native_record_budget_compact_report_remains_below_one_mebibyte(self):
|
||||
import copy,json
|
||||
from test_ordinary_gate_evidence import fixture,NONCE
|
||||
template=fixture().splitlines()
|
||||
initial=[line for line in template if 'sample=0 ' in line]
|
||||
basis=[line for line in template if 'sample=1 ' in line and 'BASIS' in line]
|
||||
ordinary=[line for line in template if 'sample=2 ' in line]
|
||||
lines=list(initial)
|
||||
for index in range(1,451):
|
||||
t=1000+index*90;world=10000+index*90
|
||||
for line in ordinary:
|
||||
line=line.replace('sample=2 real=1220','sample='+str(index)+' real='+str(t)).replace('world=10220','world='+str(world))
|
||||
if index==1:
|
||||
for ack in basis:
|
||||
if ('ERR LOOK OBSERVE:' in line and 'ERR LOOK OBSERVE:' in ack) or ('SAMPLE phase=' in line and 'ERR GATE OBSERVE:' in ack):
|
||||
lines.append(ack.replace('real=1110','real='+str(t)))
|
||||
lines.append(line)
|
||||
if index==23:lines.append(f'ERR INPUT OBSERVE: run={NONCE} sample={index} real={t+1} world={world} READY')
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('')
|
||||
observer=proof.Observer(path,NONCE,clock=lambda:10.,enable_look=True,enable_approach=True)
|
||||
try:
|
||||
with path.open('a') as out:out.write('\n'.join(lines)+'\n')
|
||||
while observer.offset<path.stat().st_size:observer.poll()
|
||||
sample=observer.approach_snapshots()[-1]
|
||||
full=[{**copy.deepcopy(sample),'sample':i} for i in range(1,451)]
|
||||
report={'approach_requested':True,'approach_proof':{'samples':full,'motions':[{'dx':128,'dy':0,'monotonic':10,'after_sample':i,'response_sample':i+1} for i in range(16)],'events':[{'key':'W','down':bool(i%2),'monotonic':10} for i in range(24)]},
|
||||
'look_observation':observer.look_records,'gate_observation':observer.gate_records,
|
||||
'observer':{'records':observer.raw,'events':observer.events},'provenance_budget':'x'*65536}
|
||||
payload=json.dumps(proof.report_for_write(report),indent=2).encode()
|
||||
self.assertEqual(len(observer.look_records),451);self.assertEqual(len(observer.gate_records),901)
|
||||
self.assertLess(len(payload),1024*1024)
|
||||
self.assertEqual(len(report['approach_proof']['samples']),450)
|
||||
self.assertIn('geometry',report['approach_proof']['samples'][0])
|
||||
finally:observer.close()
|
||||
|
||||
def test_approach_watchdog_cannot_be_renewed_by_module_notification(self):
|
||||
events=[{'kind':'report','metadata':{}},{'kind':'child','identity':{}},
|
||||
{'kind':'approach-pending','deadline_monotonic':10},
|
||||
{'kind':'approach-pending','deadline_monotonic':30}]
|
||||
class Pipe:
|
||||
def poll(self,*a):return bool(events)
|
||||
def recv(self):return events.pop(0)
|
||||
def close(self):pass
|
||||
class Process:
|
||||
pid=123;exitcode=0;alive=True;terminated=0
|
||||
def start(self):pass
|
||||
def is_alive(self):return self.alive
|
||||
def terminate(self):self.terminated+=1
|
||||
def join(self,*a):self.alive=False
|
||||
def kill(self):self.alive=False
|
||||
child=Process();pipe=Pipe();clock=Clock()
|
||||
def ticking():clock.now+=1;return clock.now
|
||||
context=SimpleNamespace(Pipe=lambda **k:(pipe,pipe),Process=lambda **k:child)
|
||||
with patch.object(proof.multiprocessing,'get_context',return_value=context),patch.object(proof.time,'monotonic',side_effect=ticking),patch.object(proof.signal,'signal'),patch.object(proof,'stop_exact_child') as stop,patch.object(proof,'supervision_failure') as failed:
|
||||
self.assertEqual(proof.supervise([]),1)
|
||||
self.assertEqual(child.terminated,1);self.assertLessEqual(clock.now,11)
|
||||
stop.assert_called_once();self.assertIsNone(failed.call_args.args[1])
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user