141 lines
12 KiB
Python
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()
|