"""Actual executor process loss against independent disposable OpenBao.""" from dataclasses import replace import hashlib import json import os import secrets from pathlib import Path import sys import time import pytest sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts')) from test_task_execution_plan import compiler from test_task_execution_native_fixture import bao_dev, owner, admit from task_execution_journal import Journal, Refused from task_execution_native_fixture import NativeFixtureAdapter from task_execution_recovery import FixtureRecoveryCustody, recover_fixture_cleanup def protected(owner,tmp_path): native,journal,binding,call,compiler,descriptor=owner directory=tmp_path/'separate-owner-custody';directory.mkdir(mode=0o700) custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32)) native=NativeFixtureAdapter(compiler,native.plan,journal,binding,native.admission,recovery_custody=custody) return native,journal,binding,call,custody def crash(native,journal,binding,stage='issued'): pid=os.fork() if pid==0: try: # The backend belongs to the independent parent, so this child cannot # waitpid it. Parent retains the actual Popen and checks it on recovery. native.admission.server_process.poll=lambda:None native.journal=Journal(journal.directory) with native.journal.locked(): admit(native,native.journal,binding) if stage=='mint-response-lost': original=native._http def lost(method,path,token,body=b''): result=original(method,path,token,body) if path=='auth/token/create':os._exit(73) return result native._http=lost native.materialize_grant() native.journal.transition(binding,'grant-issued','applying') if stage=='business-response-lost': native.metadata_call(0) original=native._http def lost(method,path,token,body=b''): result=original(method,path,token,body) if method=='PUT':os._exit(73) return result native._http=lost native.metadata_call(1) if stage=='cleanup-response-lost': from task_execution_cleanup import run_cleanup original=native._http def lost(method,path,token,body=b''): result=original(method,path,token,body) if path=='auth/token/revoke-self':os._exit(73) return result native._http=lost run_cleanup(native.journal,binding,native) os._exit(73) except BaseException:os._exit(74) _,status=os.waitpid(pid,0) assert os.waitstatus_to_exitcode(status)==73 # Original admission never appears in the independent parent or reopened DB. assert native.task is None assert journal._admission is None and journal._consumed_admission is None def permit(native,custody,binding,**kwargs): return custody.admit(binding,owner_token=kwargs.pop('owner_token',native.admission.owner_token), server_process=native.admission.server_process,**kwargs) @pytest.mark.parametrize('stage',['issued','business-response-lost','cleanup-response-lost']) def test_process_loss_then_independent_cleanup_never_resumes_business(owner,tmp_path,stage,monkeypatch,request): native,journal,binding,call,custody=protected(owner,tmp_path) crash(native,journal,binding,stage) journal=Journal(journal.directory) request.addfinalizer(journal.close) native.journal=journal private=custody.read(binding) assert private['phase']=='issued' and private['task_accessor'] target=native.compiler.before_call(native.plan,1).path before=call('GET',target) with journal.locked(): with pytest.raises(Refused):journal.reserve(binding,native.cleanup) with pytest.raises(Refused):native.materialize_grant() with pytest.raises(Refused):native.metadata_call(0) with pytest.raises(Refused):native.revoke_task_token(native.cleanup[0].call_digest) admitted=permit(native,custody,binding) import task_execution_recovery as recovery_module sent=[] original_request=recovery_module.http.client.HTTPConnection.request def tracked(connection,method,url,*args,**kwargs): sent.append((method,url)) # metadata only; never capture body/headers return original_request(connection,method,url,*args,**kwargs) with monkeypatch.context() as patch: patch.setattr(recovery_module.http.client.HTTPConnection,'request',tracked) result=recover_fixture_cleanup(journal,admitted) assert all((method,path) in { ('POST','/v1/auth/token/revoke-accessor'),('POST','/v1/auth/token/lookup-accessor'), ('GET','/v1/sys/policies/acl/'+native.policy_name), ('DELETE','/v1/sys/policies/acl/'+native.policy_name)} for method,path in sent) assert result['confirmed'] for accessor in [private['task_accessor'],private['identity_accessor']]: assert call('POST','auth/token/lookup-accessor',{'accessor':accessor})[0]==400 assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404 after=call('GET',target) assert after[0]==before[0] and after[1].get('data')==before[1].get('data') # request IDs vary assert journal.observe(binding)['dispatch_allowed'] is False if stage!='issued':assert journal.observe(binding)['external_outcome_unknown'] if stage=='cleanup-response-lost': original=json.loads(journal.db.execute('SELECT original FROM recovery_attempts').fetchone()[0]) assert original[0]['outcome']=='pending' assert journal.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE outcome='pending'").fetchone()[0]==1 with pytest.raises(Refused):recover_fixture_cleanup(journal,admitted) public=(journal.directory/'journal.sqlite').read_bytes() for value in [private['task_accessor'],private['identity_accessor'],native.admission.owner_token]: assert value.encode() not in public assert 'client_token' not in json.dumps(private) assert 'token' not in repr(admitted) and private['task_accessor'] not in repr(admitted) def test_process_loss_before_handle_commit_preserves_unknown_mint_debt(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path) crash(native,journal,binding,'mint-response-lost') assert custody.read(binding)['phase']=='mint-intent' result=recover_fixture_cleanup(journal,permit(native,custody,binding)) assert not result['confirmed'] assert [x['outcome'] for x in result['obligations']]==['owed','confirmed','confirmed'] assert journal.observe(binding)['external_outcome_unknown'] assert journal.db.execute('SELECT confirmed FROM recovery_results ORDER BY obligation').fetchall()==[(0,),(1,),(1,)] def test_revoked_expired_wrong_revision_and_public_flag_refuse_before_calls(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding) p=permit(native,custody,binding) custody.revoke(p) for bad in [p,replace(permit(native,custody,binding),deadline=time.monotonic()-1), replace(permit(native,custody,binding),private_revision_digest='0'*64),{'verified':True}]: with pytest.raises(Refused):recover_fixture_cleanup(journal,bad) assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200 assert journal.cleanup_status(binding)['obligations'][0]['outcome']=='owed' def test_revoked_recovery_credential_cannot_fake_cleanup_success(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding) status,data=call('POST','auth/token/create',{'policies':['root'],'ttl':'30s'}) assert status==200 token=data['auth']['client_token'] assert call('POST','auth/token/revoke',{'token':token})[0] in {200,204} result=recover_fixture_cleanup(journal,permit(native,custody,binding,owner_token=token)) assert not result['confirmed'] and all(x['outcome']=='owed' for x in result['obligations']) assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200 # A separately admitted owner attempt can recover after the failed attempt; # neither the failed attempt nor its receipt grants an automatic retry. assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed'] assert journal.db.execute('SELECT COUNT(*) FROM recovery_attempts').fetchone()[0]==2 def test_policy_name_reuse_never_deletes_different_policy(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding) replacement='path "unrelated" { capabilities = ["read"] }' assert call('PUT','sys/policies/acl/'+native.policy_name,{'policy':replacement})[0] in {200,204} result=recover_fixture_cleanup(journal,permit(native,custody,binding)) assert not result['confirmed'] assert [x['outcome'] for x in result['obligations']]==['confirmed','confirmed','owed'] assert call('GET','sys/policies/acl/'+native.policy_name)[1]['data']['policy']==replacement def test_private_custody_permissions_and_identity_binding_fail_closed(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path) path=custody._path(binding);path.chmod(0o644) with pytest.raises(Refused):custody.read(binding) path.chmod(0o600) data=json.loads(path.read_bytes());data['payload']['binding']['operation_id']='other' path.write_text(json.dumps(data)) with pytest.raises(Refused):custody.read(binding) def test_lost_recovery_response_stays_unknown_until_new_independent_attempt(owner,tmp_path,monkeypatch): native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding) import task_execution_recovery as recovery_module original=recovery_module.http.client.HTTPConnection.getresponse once=[True] def lost(connection): response=original(connection) if once[0]: once[0]=False response.close() raise Refused('synthetic lost recovery response after real revoke') return response first=permit(native,custody,binding) with monkeypatch.context() as patch: patch.setattr(recovery_module.http.client.HTTPConnection,'getresponse',lost) assert not recover_fixture_cleanup(journal,first)['confirmed'] assert journal.observe(binding)['external_outcome_unknown'] with pytest.raises(Refused):recover_fixture_cleanup(journal,first) assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed'] # Reconciliation does not erase the historic uncertain invocation. assert journal.observe(binding)['external_outcome_unknown'] assert journal.db.execute("SELECT COUNT(*) FROM recovery_calls WHERE outcome='pending'").fetchone()[0]==1 @pytest.fixture def capsule(tmp_path): from task_execution_journal import Binding,Cleanup from task_execution_plan import cleanup_specs binding=Binding('SYNTHETIC','sha256:'+'a'*64,'12345678-1234-1234-1234-123456789abc','b'*64) directory=tmp_path/'private-capsule';directory.mkdir(mode=0o700) custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32)) cleanup=tuple(Cleanup(x['kind'],x['call_digest']) for x in cleanup_specs(binding.operation_id)) custody.prepare(binding,cleanup,12345,'path "fixture" {}\n','synthetic-private-accessor') return custody,binding @pytest.mark.parametrize('mutate',[ lambda v:v.update(extra=True),lambda v:v.pop('phase'),lambda v:v.update(binding=True), lambda v:v['binding'].update(approval_id=True),lambda v:v['binding'].update(extra='x'), lambda v:v['binding'].update(request_digest='0'*64),lambda v:v.update(port=True), lambda v:v.update(port=12345.0),lambda v:v.update(port=0),lambda v:v.update(phase=[]), lambda v:v.update(phase='issued'),lambda v:v.update(revision=False), lambda v:v.update(revision=0.0),lambda v:v.update(identity_accessor=False), lambda v:v.update(task_accessor='unexpected-before-issue'),lambda v:v.update(policy_hcl=[]), lambda v:v.update(cleanup={}),lambda v:v['cleanup'].pop(), lambda v:v['cleanup'].__setitem__(0,False),lambda v:v['cleanup'][0].update(extra=True), lambda v:v['cleanup'][0].update(kind=True),lambda v:v['cleanup'][0].update(call_digest=0), lambda v:v['cleanup'][0].update(call_digest='f'*64),lambda v:v['cleanup'].reverse(), ]) def test_authenticated_but_damaged_capsule_schema_refuses(capsule,mutate): custody,binding=capsule value=custody.read(binding);mutate(value) # Trusted test owner re-signs malformed payload to exercise schema defenses # independently of MAC validation; untrusted corruption cannot do this. custody._write(binding,value) with pytest.raises(Refused):custody.read(binding) def test_capsule_corruption_wrong_key_duplicate_and_nonfinite_refuse(capsule): custody,binding=capsule;path=custody._path(binding);original=path.read_bytes() value=json.loads(original);value['payload']['identity_accessor']='different-synthetic-accessor' path.write_text(json.dumps(value)) with pytest.raises(Refused):custody.read(binding) path.write_bytes(original) other=FixtureRecoveryCustody(custody.directory,integrity_key=b'X'*32) with pytest.raises(Refused):other.read(binding) for raw in [b'{',original[:-1]+b',"mac":"duplicate"}',original.replace(b'12345',b'NaN'),b'[]',b'{}',b'{"payload":{},"mac":"'+b'\xc3\xa9'*64+b'"}']: path.write_bytes(raw) with pytest.raises(Refused):custody.read(binding) path.write_bytes(original) assert custody.read(binding)['identity_accessor']=='synthetic-private-accessor' def test_exact_absence_and_single_terminal_lf_contract(): from task_execution_recovery import _policy_absent,_same_policy assert _policy_absent(404,{'errors':[]}) for status,body in [(403,{'errors':[]}),(404,{}),(404,{'errors':['denied']}), (404,{'errors':None}),(404,{'errors':[]} | {'data':{}}), (404,{'errors':False}),(404,{'errors':0}),(404,{'errors':{}})]: assert not _policy_absent(status,body) assert _same_policy('fixture','fixture\n') assert _same_policy('fixture\n','fixture\n') for text in ['fixture \n','fixture\t','fixture\r\n','fixture\n\n',False,None]: assert not _same_policy(text,'fixture\n') @pytest.mark.parametrize('after_delete',[False,True]) def test_recovery_policy_absence_rejects_misleading_native_http_bodies(owner,tmp_path,monkeypatch,after_delete): native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding) import task_execution_recovery as module malformed=[{}, {'errors':['permission denied']}, {'errors':None}, {'errors':False}, {'errors':0}, {'errors':[],'data':{}}, {'errors':['not really absent']}, b'{"errors":["permission denied"],"errors":[]}',b'{"errors":NaN}',b'not JSON'] path='sys/policies/acl/'+native.policy_name for body in malformed: sent=[];gets=[0] class Response: def __init__(self,status,data):self.status=status;self.data=data def read(self,limit):return self.data if type(self.data) is bytes else json.dumps(self.data).encode() class Connection: def __init__(self,*args,**kwargs):pass def request(self,method,url,**kwargs):sent.append((method,url)) def getresponse(self): method,url=sent[-1] if url=='/v1/'+path: if method=='DELETE':return Response(204,{}) gets[0]+=1 if after_delete and gets[0]==1: return Response(200,{'data':{'policy':native.hcl[:-1]}}) return Response(404,body) return Response(403,{'errors':['synthetic refusal']}) def close(self):pass with monkeypatch.context() as patch: patch.setattr(module.http.client,'HTTPConnection',Connection) result=recover_fixture_cleanup(journal,permit(native,custody,binding)) assert not result['confirmed'] and result['obligations'][2]['outcome']=='owed' assert ('DELETE','/v1/'+path) in sent if after_delete else ('DELETE','/v1/'+path) not in sent # The denied/malformed probes above emitted no real native calls. assert call('GET',path)[0]==200 assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed'] def test_authenticated_prepared_capsule_rollback_cannot_hide_later_mint(owner,tmp_path): native,journal,binding,call,custody=protected(owner,tmp_path) old=custody._path(binding).read_bytes() crash(native,journal,binding) current=custody.read(binding) custody._path(binding).write_bytes(old) # Still authentic under the owner key, but the durable journal proves the # operation reached issuance. The stale prepared record cannot prove absence. result=recover_fixture_cleanup(journal,permit(native,custody,binding)) assert not result['confirmed'] and result['obligations'][0]['outcome']=='owed' assert call('POST','auth/token/lookup-accessor',{'accessor':current['task_accessor']})[0]==200 assert call('POST','auth/token/revoke-accessor',{'accessor':current['task_accessor']})[0] in {200,204}