Files
EscapistsReintroduced/tests/test_packed_external_game.py
T

141 lines
12 KiB
Python

#!/usr/bin/env python3
"""Synthetic v3 packaging/runtime boundary tests; no native process or real addon edits."""
import copy,hashlib,json,os,re,shutil,sys,tempfile,unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT=Path(os.environ.get('ERR_TEST_REPO',str(Path(__file__).resolve().parents[1])))
sys.path.insert(0,str(ROOT/'tools'))
if os.environ.get('ERR_PACKED_TOOLS'):sys.path.insert(0,os.environ['ERR_PACKED_TOOLS'])
import headless_game as legacy
import packed_external as v3
import external_addons as ext
class ExternalPackedTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory(prefix='erri-packed-chat-v3test-');self.addCleanup(self.tmp.cleanup);self.home=Path(self.tmp.name)
self.root=self.home/'repo';(self.root/'docs').mkdir(parents=True)
self.source=self.home/'installed';self.source.mkdir();self.value=ext.policy(ROOT)
for i,(guid,deps) in enumerate(ext.ALLOWED.items()):
folder=self.source/guid;folder.mkdir();text='GameProject { ID "External_'+str(i)+'" GUID "'+guid+'" Dependencies { '+' '.join('"'+d+'"' for d in sorted(deps))+' } }\n'
(folder/'addon.gproj').write_text(text);self.value['addons'][guid]['project_sha256']=hashlib.sha256(text.encode()).hexdigest()
data=b'fixture rdb\0'
for path,item in self.value['resources'].items():
if item['addon']==guid:data+=path.encode()+b'\0\x06\0\0\0\0\0'+bytes.fromhex(item['guid'])[::-1]
(folder/'resourceDatabase.rdb').write_bytes(data);(folder/'data.pak').write_bytes(b'fixture '+guid.encode())
(self.root/'docs/external-addons.json').write_text(json.dumps(self.value))
self.candidate=self.home/'packages';self.candidate.mkdir()
for key,(name,guid,deps) in v3.PROJECTS.items():
folder=self.candidate/key;folder.mkdir();ids={'58D0FB3206B6F859',*(v3.PROJECTS[d][1] for d in deps),*ext.direct_for(key)}
text='GameProject { ID "'+name+'" GUID "'+guid+'" TITLE "Fixture" Dependencies { '+' '.join('"'+d+'"' for d in sorted(ids))+' } }\n'
(folder/'addon.gproj').write_text(text);(folder/'data.pak').write_bytes(b'synthetic '+key.encode());(folder/'resourceDatabase.rdb').write_bytes(b'synthetic '+key.encode())
# Session is a new direct /tmp child, separate from the fixture parent.
session=tempfile.mkdtemp(prefix='erri-packed-chat-v3-');self.session=Path(session);self.session.rmdir();self.addCleanup(lambda:shutil.rmtree(self.session,ignore_errors=True))
self.profile=self.session/'profile';self.steam=self.home/'Steam';self.vanilla=self.steam/'steamapps/common/Arma Reforger/addons/data';self.vanilla.mkdir(parents=True)
self.args=SimpleNamespace(project='chat',packed_addons=self.candidate,packed_core=None,packed_session=self.session,profile=Path('/tmp/erri-game-profile'),settle=15,external_addons=self.source)
self.packed=v3.request(self.args,self.root)
def prepare(self):
legacy.prepare_packed(self.packed);(self.profile/'addons').mkdir(parents=True);(self.profile/'addons/ArmaReforger').symlink_to(self.vanilla,target_is_directory=True)
v3.prepare_profile(self.packed,self.steam,self.profile)
def snapshot(self):return legacy.packed_snapshot(self.packed,['fixture-native-command'],self.profile)
def seal(self,raw):return legacy.packed_seal(self.packed,raw,None)
def content(self):
game=self.steam/'steamapps/common/Arma Reforger'
records=[('5614BBCCBB55ED1C','core',game/'addons/core','core.gproj',2),('58D0FB3206B6F859','ArmaReforger',self.profile/'addons/ArmaReforger','ArmaReforger.gproj',14)]
records += [(guid,name,self.packed['input']/key,'addon.gproj',1) for key,(name,guid,_) in v3.PROJECTS.items()]
records += [(guid,self.packed['external_names'][guid],self.profile/'addons'/('ERRI_'+guid),'addon.gproj',1) for guid in self.packed['external_records']]
text=f"00 ENGINE : dir: '{self.packed['input']}'\n00 ENGINE : dir: './addons'\n00 ENGINE : dir: '{self.profile}/addons'\n00 ENGINE : Loaded addons:\n"
for guid,name,path,descriptor,count in records:text+=f"00 ENGINE : gproj: '{path/descriptor}' guid: '{guid}'\n"
for i,(guid,name,path,descriptor,count) in enumerate(records):
text+=f"00 ENGINE : FileSystem: Adding package '{path}/' (pak count: {count}) to filesystem under name {name}\n"
text+=f"00 RESOURCES : ResourceDB: loading cache (id={i} name={name} path={path}/resourceDatabase.rdb)\n"
return text+'''00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"
00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"
00 SCRIPT : ERR ORDINARY: header verified project=chat systems=MissionSystems
00 SCRIPT : ERR ORDINARY: local participant verified project=chat phase=prison
00 SCRIPT : Rereintroduced [mission]: phase 0 -> 1
'''
def evidence(self,text=None):return legacy.packed_evidence(self.content() if text is None else text,self.packed,self.steam,self.profile)
def test_explicit_v3_and_frozen_v2_rejection(self):
with patch.object(legacy,'ROOT',self.root):self.assertEqual(legacy.packed_request(self.args)['schema_version'],3)
with self.assertRaises(ValueError):legacy.packed_payload_inventory(self.candidate,'chat')
self.assertEqual(set(v3.inventory(self.candidate)),{key+'/'+name for key in v3.PROJECTS for name in v3.FILES})
self.assertEqual(v3.PROJECTS,legacy.PACKED_PROJECTS)
def test_request_gates(self):
for changes in [{'project':'core'},{'packed_core':self.candidate},{'packed_addons':None},{'packed_session':None},{'settle':14},{'profile':self.home/'profile'},{'packed_session':self.home/'nested'},{'packed_session':self.home}]:
with self.subTest(changes=changes):
args=copy.copy(self.args);vars(args).update(changes)
with self.assertRaises(ValueError):v3.request(args,self.root)
def test_exact_identity_closure_and_no_loose_files(self):
path=self.candidate/'ai/addon.gproj';original=path.read_text()
for text in [original.replace('"'+ext.DIRECT[0]+'"','"5F16D7E4A1CBE075"'),original.replace('Dependencies {','Dependencies { "'+ext.DIRECT[0]+'"'),original.replace('ERR_Ai','Not_Ai')]:
path.write_text(text)
with self.assertRaises(ValueError):v3.inventory(self.candidate)
path.write_text(original);(self.candidate/'core/loose.c').write_text('bad')
with self.assertRaises(ValueError):v3.inventory(self.candidate)
def test_private_copy_exact_six_links_and_schema(self):
unrelated=self.source/'protected';unrelated.mkdir();(unrelated/'addon.gproj').write_text('GameProject {GUID "5F16D7E4A1CBE075"}')
self.prepare();raw=self.snapshot();value=json.loads(raw)
self.assertEqual(value['schema_version'],3);self.assertEqual(value['kind'],'packed-chat-external-runtime-input');self.assertEqual(len(value['external_addons']),6)
self.assertEqual({p.name for p in (self.profile/'addons').iterdir()},{'ArmaReforger',*('ERRI_'+g for g in ext.ALLOWED)})
self.assertEqual(self.seal(raw)['packed_inputs_unchanged_after_run'],True)
self.assertNotEqual((self.candidate/'ai/data.pak').stat().st_ino,(self.packed['input']/'ai/data.pak').stat().st_ino)
self.assertFalse(value.get('release_qualified',False))
def test_candidate_and_copy_mutations_refused(self):
self.prepare();raw=self.snapshot();(self.packed['input']/'core/data.pak').write_bytes(b'changed')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
with self.assertRaises(ValueError):self.snapshot()
def test_mutated_candidate_before_copy_refused(self):
(self.candidate/'core/data.pak').write_bytes(b'changed')
with self.assertRaises(ValueError):legacy.prepare_packed(self.packed)
self.assertFalse(self.session.exists())
def test_external_runtime_mutation_refused(self):
self.prepare();raw=self.snapshot();(self.source/ext.DIRECT[0]/'data.pak').write_bytes(b'changed')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_external_loose_source_refused(self):
self.prepare();raw=self.snapshot();(self.source/ext.DIRECT[0]/'Scripts').mkdir()
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_external_link_retarget_refused(self):
self.prepare();raw=self.snapshot();link=self.profile/'addons'/('ERRI_'+ext.DIRECT[0]);link.unlink();link.symlink_to(self.source/ext.DIRECT[1])
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_policy_and_cache_mutations_refused(self):
self.prepare();raw=self.snapshot();policy=self.root/'docs/external-addons.json';old=policy.read_bytes();policy.write_bytes(old+b' ')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);policy.write_bytes(old)
self.packed['cache_path'].write_text('{}')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_profile_contamination_and_vanilla_retarget_refused(self):
self.prepare();raw=self.snapshot();(self.profile/'addons/original').mkdir()
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);(self.profile/'addons/original').rmdir()
link=self.profile/'addons/ArmaReforger';link.unlink();link.symlink_to(self.source)
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_provenance_tamper_refused(self):
self.prepare();raw=self.snapshot();(self.session/v3.MANIFEST).write_bytes(raw+b' ')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_valid_full_thirteen_mount_database_proof(self):
result=self.evidence();self.assertTrue(result['ready'],result);self.assertFalse(result['release_qualified']);self.assertEqual(result['external_addon_count'],6)
def test_runtime_evidence_rejects_redirects_extra_missing_and_loose(self):
content=self.content();guid=ext.DIRECT[0];name=self.packed['external_names'][guid];folder=self.profile/'addons'/('ERRI_'+guid)
variants=[content.replace(str(folder),'/unreviewed'),content.replace('(pak count: 1) to filesystem under name '+name,'(pak count: 2) to filesystem under name '+name),content+'00 ENGINE : dir: "ignored"\n'+f"00 ENGINE : dir: '{self.source}'\n",content+f"00 ENGINE : FileSystem: Adding relative directory '{folder}/Scripts' to filesystem under name {name}\n",re.sub(r'[^\n]*gproj:[^\n]*'+guid+r'[^\n]*\n','',content),re.sub(r'[^\n]*ResourceDB: loading cache[^\n]*name='+name+r' [^\n]*\n','',content),content.replace('PLATFORM_WINDOWS','PLATFORM_WINDOWS,WORKBENCH')]
for text in variants:
with self.subTest():self.assertFalse(self.evidence(text)['ready'])
def test_crlf_project_and_exact_multiple_external_paks(self):
path=self.candidate/'ai/addon.gproj';path.write_bytes(path.read_bytes().replace(b'\n',b'\r\n'))
self.assertEqual(len(v3.inventory(self.candidate)),15)
guid=ext.DIRECT[0];(self.source/guid/'data001.pak').write_bytes(b'second synthetic pak')
self.packed=v3.request(self.args,self.root)
name=self.packed['external_names'][guid];text=self.content().replace('(pak count: 1) to filesystem under name '+name,'(pak count: 2) to filesystem under name '+name)
self.assertTrue(self.evidence(text)['ready']);self.assertFalse(self.evidence()['ready'])
def test_redirected_profile_refused(self):
self.prepare();raw=self.snapshot();moved=self.session/'redirected';self.profile.rename(moved);self.profile.symlink_to(moved,target_is_directory=True)
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
def test_saves_addon_and_native_provenance_tamper_refused(self):
self.prepare();raw=self.snapshot();saves=self.profile/'addons/saves';saves.mkdir();(saves/'addon.gproj').write_text('unreviewed')
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);shutil.rmtree(saves)
native=self.profile/'logs/logs_2026-09-20_12-34-56';native.mkdir(parents=True);(native/v3.MANIFEST).write_bytes(raw+b' ')
self.assertFalse(legacy.packed_seal(self.packed,raw,native)['packed_inputs_unchanged_after_run'])
def test_native_errors_are_not_waived(self):
result=self.evidence(self.content()+'00 RESOURCES (E): Native resource error\n')
self.assertTrue(result['failed']);self.assertFalse(result['ready'])
if __name__=='__main__':unittest.main()