Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
181 lines
13 KiB
Python
181 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Packed input, path and provenance regression; never launches a native process."""
|
|
from pathlib import Path
|
|
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
|
|
import headless_game as runner
|
|
|
|
PROJECT = '''GameProject {
|
|
ID "ERR_Core"
|
|
GUID "2528466DE220D91C"
|
|
TITLE "Escapists Reintroduced - Core"
|
|
Dependencies { "58D0FB3206B6F859" }
|
|
}
|
|
'''
|
|
|
|
class PackedCoreTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix='erri-packed-core-test-')
|
|
self.root = Path(self.temp.name)
|
|
self.candidate = self.root/'candidate';self.candidate.mkdir()
|
|
for name,data in {'addon.gproj':PROJECT.encode(),'data.pak':b'fixture-package-bytes','resourceDatabase.rdb':b'fixture-database'}.items():
|
|
(self.candidate/name).write_bytes(data)
|
|
self.session_temp = tempfile.TemporaryDirectory(prefix='erri-packed-core-session-')
|
|
self.session = Path(self.session_temp.name);self.session.rmdir()
|
|
self.args = SimpleNamespace(packed_core=self.candidate,packed_session=self.session,project='core',profile=Path('/tmp/erri-game-profile'),settle=15)
|
|
self.packed = runner.packed_request(self.args)
|
|
self.steam = Path('/Steam');self.profile=self.session/'profile'
|
|
def tearDown(self):
|
|
self.session_temp.cleanup();self.temp.cleanup()
|
|
def content(self):
|
|
core=self.packed['input'];game=self.steam/'steamapps/common/Arma Reforger'
|
|
return f'''00 ENGINE : Addon dirs:
|
|
00 ENGINE : dir: '{core.parent}'
|
|
00 ENGINE : dir: './addons'
|
|
00 ENGINE : dir: '{self.profile}/addons'
|
|
00 ENGINE : Loaded addons:
|
|
00 ENGINE : gproj: './addons/core/core.gproj' guid: '5614BBCCBB55ED1C'
|
|
00 ENGINE : gproj: './addons/data/ArmaReforger.gproj' guid: '58D0FB3206B6F859'
|
|
00 ENGINE : gproj: '{core}/addon.gproj' guid: '2528466DE220D91C'
|
|
00 ENGINE : FileSystem: Adding package './addons/core/' (pak count: 2) to filesystem under name core
|
|
00 ENGINE : FileSystem: Adding package './addons/data/' (pak count: 14) to filesystem under name ArmaReforger
|
|
00 ENGINE : FileSystem: Adding package '{core}/' (pak count: 1) to filesystem under name ERR_Core
|
|
00 RESOURCES : ResourceDB: loading cache (id=0 name=ERR_Core path={core}/resourceDatabase.rdb)
|
|
00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"
|
|
00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"
|
|
00 SCRIPT : ERR ORDINARY: header verified project=core systems=MissionSystems
|
|
00 SCRIPT : ERR ORDINARY: local participant verified project=core phase=prison
|
|
00 SCRIPT : Reintroduced [mission]: phase 0 -> 1
|
|
'''
|
|
def evidence(self,content=None):
|
|
return runner.packed_evidence(self.content() if content is None else content,self.packed,self.steam,self.profile)
|
|
def test_exact_three_files_hash_pak_and_rdb(self):
|
|
inventory=runner.packed_inventory(self.candidate)
|
|
self.assertEqual(set(inventory),runner.PACKED_FILES)
|
|
self.assertEqual(inventory['data.pak']['sha256'],hashlib.sha256(b'fixture-package-bytes').hexdigest())
|
|
before=inventory['resourceDatabase.rdb'];(self.candidate/'resourceDatabase.rdb').write_bytes(b'changed')
|
|
self.assertNotEqual(before,runner.packed_inventory(self.candidate)['resourceDatabase.rdb'])
|
|
def test_missing_extra_empty_and_symlink_refused(self):
|
|
for name in runner.PACKED_FILES:
|
|
with self.subTest(name=name):
|
|
p=self.candidate/name;data=p.read_bytes();p.unlink()
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
|
|
p.write_bytes(b'')
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
|
|
p.unlink();p.symlink_to(self.root/'absent')
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
|
|
p.unlink();p.write_bytes(data)
|
|
extra=self.candidate/'Scripts';extra.mkdir()
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
|
|
def test_wrong_guid_dependency_and_project_identity_refused(self):
|
|
for bad in (PROJECT.replace('2528466DE220D91C','FA804F411BDDF2C6'),PROJECT.replace('58D0FB3206B6F859','5192138CB9D5B911'),PROJECT.replace('ERR_Core','ERR_Chat'),PROJECT.replace('Dependencies {','Dependencies { "5192138CB9D5B911"')):
|
|
(self.candidate/'addon.gproj').write_text(bad)
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
|
|
def test_candidate_symlink_or_alias_refused(self):
|
|
alias=self.root/'alias';alias.symlink_to(self.candidate,target_is_directory=True)
|
|
with self.assertRaises(ValueError):runner.packed_inventory(alias)
|
|
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate/'..'/'candidate')
|
|
def test_request_requires_new_core_session_and_canonical_settle(self):
|
|
for key,value in [('project','chat'),('packed_core',None),('packed_session',None),('settle',5),('profile',Path('/other')),('packed_session',Path('/tmp/unsafe')),('packed_session',self.root/'erri-packed-core-nested')]:
|
|
args=SimpleNamespace(**vars(self.args));setattr(args,key,value)
|
|
with self.subTest(key=key,value=value),self.assertRaises(ValueError):runner.packed_request(args)
|
|
self.session.mkdir()
|
|
with self.assertRaises(ValueError):runner.packed_request(self.args)
|
|
def test_ordinary_request_untouched(self):
|
|
self.assertIsNone(runner.packed_request(SimpleNamespace(project='core')))
|
|
def test_copy_is_exclusive_regular_independent_and_exact(self):
|
|
runner.prepare_packed(self.packed)
|
|
self.assertEqual(runner.packed_inventory(self.packed['input']),self.packed['manifest'])
|
|
for name in runner.PACKED_FILES:
|
|
self.assertNotEqual((self.candidate/name).stat().st_ino,(self.packed['input']/name).stat().st_ino)
|
|
with self.assertRaises(FileExistsError):runner.prepare_packed(self.packed)
|
|
def test_changed_candidate_before_copy_refused(self):
|
|
(self.candidate/'data.pak').write_bytes(b'new')
|
|
with self.assertRaisesRegex(ValueError,'changed'):runner.prepare_packed(self.packed)
|
|
def test_command_only_private_input_never_repository(self):
|
|
command=runner.command_for(Path('/never-loose-repo'),self.steam,Path('/compat'),self.profile,'core',self.packed)
|
|
self.assertEqual(command[command.index('-addonsDir')+1],'Z:'+str(self.packed['input'].parent))
|
|
self.assertEqual(command[command.index('-addons')+1],runner.PACKED_GUID)
|
|
self.assertNotIn('-gproj',command);self.assertNotIn('-server',command)
|
|
self.assertFalse(any('/never-loose-repo' in part or str(self.candidate) in part for part in command))
|
|
for flag in ('-noSplash','-noBackend','-noSound','-errOrdinaryScenario'):self.assertIn(flag,command)
|
|
def test_exact_native_paths_modules_and_readiness_required(self):
|
|
report=self.evidence();self.assertTrue(report['ready']);self.assertTrue(report['packed_only']);self.assertFalse(report['failed'])
|
|
self.assertFalse(report['build_qualified']);self.assertFalse(report['release_qualified'])
|
|
for missing in ('Loaded addons:','FileSystem: Adding package','ResourceDB: loading cache','Module: GameLib;','local participant verified','header verified'):
|
|
with self.subTest(missing=missing):self.assertFalse(self.evidence(self.content().replace(missing,'missing'))['ready'])
|
|
def test_available_addons_cannot_substitute_loaded_evidence(self):
|
|
self.assertFalse(self.evidence(self.content().replace('Loaded addons:','Available addons:'))['ready'])
|
|
def test_redirected_same_guid_foreign_duplicate_or_loose_mount_fails(self):
|
|
source=self.content();entry=f"00 ENGINE : gproj: '{self.packed['input']}/addon.gproj' guid: '2528466DE220D91C'\n"
|
|
faults=[source.replace(entry,entry.replace(str(self.packed['input']),'/loose/core')),source.replace(entry,entry+entry),source.replace(entry,entry+"00 ENGINE : gproj: '/foreign/addon.gproj' guid: 'invalid'\n"),source.replace(entry,entry+"00 ENGINE : gproj: '/foreign/addon.gproj' guid: '5192138CB9D5B911'\n"),source+"00 ENGINE : FileSystem: Adding relative directory '/loose/core' to filesystem under name ERR_Core\n",source.replace('(pak count: 1)','(pak count: 2)'),source+"00 ENGINE : dir: '/loose/addons'\n",source.replace(f'path={self.packed["input"]}/resourceDatabase.rdb','path=/foreign/resourceDatabase.rdb')]
|
|
for content in faults:
|
|
with self.subTest(content=content):self.assertTrue(self.evidence(content)['failed']);self.assertFalse(self.evidence(content)['ready'])
|
|
def test_one_wrong_loaded_block_cannot_be_hidden_by_later_good_block(self):
|
|
bad=self.content().replace("guid: '2528466DE220D91C'","guid: '5192138CB9D5B911'")
|
|
self.assertTrue(self.evidence(bad+self.content())['failed'])
|
|
def test_exact_native_profile_addons_alias_only(self):
|
|
path=str(self.profile/'addons')
|
|
good=self.content().replace("dir: '"+path+"'", "dir: '$profile:../addons/'")
|
|
self.assertTrue(self.evidence(good)['ready'])
|
|
for alias in ('$profile:../../addons/', '$profile:../addons/../evil', '$other:../addons/', '$profile:../addons', '$profile:../addons//', '$profile:../addons/../../source'):
|
|
bad=self.content().replace("dir: '"+path+"'", "dir: '"+alias+"'")
|
|
with self.subTest(alias=alias):self.assertTrue(self.evidence(bad)['failed'])
|
|
def test_native_windows_path_and_trailing_slash_normalize(self):
|
|
content=self.content().replace(str(self.packed['input']),'Z:'+str(self.packed['input']).replace('/','\\'))
|
|
self.assertTrue(self.evidence(content)['ready'])
|
|
def test_raw_errors_fixture_and_workbench_fail(self):
|
|
for content in (self.content()+'00 SCRIPT (E): example failure\n',self.content()+'ERR SMOKE PASS: unexpected\n',self.content().replace('PLATFORM_WINDOWS','PLATFORM_WINDOWS,WORKBENCH')):
|
|
self.assertTrue(self.evidence(content)['failed'])
|
|
def test_canonical_ready_settle_and_error_latch(self):
|
|
monitor=runner.StartupMonitor(0,180,15,0)
|
|
self.assertIsNone(monitor.observe(self.evidence(),1));self.assertIsNone(monitor.observe(self.evidence(),15))
|
|
self.assertEqual(monitor.observe(self.evidence(),16),'game-started')
|
|
bad=self.evidence(self.content()+'00 SCRIPT (E): late\n')
|
|
self.assertEqual(monitor.observe(bad,16.1),'game-failed')
|
|
self.assertTrue(monitor.annotate(self.evidence())['failed'])
|
|
def test_packed_manifest_seals_exact_copy_candidate_and_console(self):
|
|
runner.prepare_packed(self.packed)
|
|
raw=runner.packed_snapshot(self.packed,['native-command'],self.profile)
|
|
metadata=json.loads(raw);self.assertEqual(metadata['artifacts'],self.packed['manifest'])
|
|
self.assertEqual(metadata['schema_version'],1);self.assertEqual(metadata['kind'],'packed-core-runtime-input')
|
|
self.assertNotIn('project',metadata);self.assertNotIn('addon_order',metadata)
|
|
self.assertEqual((self.session/runner.PACKED_MANIFEST).read_bytes(),raw)
|
|
log=self.session/'log';log.mkdir();(log/'console.log').write_text(self.content())
|
|
result=runner.packed_seal(self.packed,raw,log)
|
|
self.assertTrue(result['packed_inputs_unchanged_after_run']);self.assertEqual(result['console_sha256'],hashlib.sha256((log/'console.log').read_bytes()).hexdigest())
|
|
self.assertNotIn('source_provenance',result);self.assertNotIn('source_unchanged_after_run',result)
|
|
(self.packed['input']/'data.pak').write_bytes(b'changed')
|
|
self.assertFalse(runner.packed_seal(self.packed,raw,log)['packed_inputs_unchanged_after_run'])
|
|
def test_missing_input_after_run_is_failure_evidence_not_exception(self):
|
|
runner.prepare_packed(self.packed);raw=runner.packed_snapshot(self.packed,[],self.profile)
|
|
(self.candidate/'resourceDatabase.rdb').unlink();result=runner.packed_seal(self.packed,raw,None)
|
|
self.assertFalse(result['packed_inputs_unchanged_after_run']);self.assertIsNotNone(result['packed_input_error'])
|
|
def test_changed_inputs_before_launch_refuse(self):
|
|
runner.prepare_packed(self.packed);(self.candidate/'data.pak').write_bytes(b'changed')
|
|
with self.assertRaises(ValueError):runner.packed_snapshot(self.packed,[],self.profile)
|
|
|
|
def test_failed_native_launch_retains_packed_manifest_without_source_snapshot(self):
|
|
steam=self.root/'steam';compat=self.root/'compat';(compat/'pfx').mkdir(parents=True);(compat/'pfx/system.reg').write_text('test')
|
|
for relative in ['steamapps/common/Proton - Experimental/proton','steamapps/common/Arma Reforger/ArmaReforgerSteam.exe']:
|
|
file=steam/relative;file.parent.mkdir(parents=True,exist_ok=True);file.write_bytes(b'not executed')
|
|
args=SimpleNamespace(**vars(self.args),steam=steam,compat=compat,timeout=60,failure_grace=0)
|
|
with patch.object(runner,'assert_no_game'),patch.object(runner,'assert_prefix_idle'),patch.object(runner.shutil,'which',return_value='/mock/tool'),patch.object(runner.subprocess,'check_output',return_value='11'),patch.object(runner.subprocess,'run'),patch.object(runner.subprocess,'Popen',side_effect=OSError('explicit test launch failure')),patch.object(runner,'snapshot',side_effect=AssertionError('loose source provenance forbidden')),patch.object(runner,'seal_run',side_effect=AssertionError('loose source seal forbidden')),contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(runner.run(args),1)
|
|
report=json.loads((self.session/'packed-runner.json').read_text())
|
|
self.assertEqual(report['result'],'runner-failed');self.assertTrue(report['packed_only']);self.assertFalse(report['packed_consumption_verified'])
|
|
self.assertNotIn('launch_source_sha256',report);self.assertTrue((self.session/runner.PACKED_MANIFEST).is_file())
|
|
self.assertTrue((self.session/'profile/addons/ArmaReforger').is_symlink())
|
|
self.assertEqual(runner.packed_inventory(self.packed['input']),self.packed['manifest'])
|
|
|
|
if __name__=='__main__':unittest.main()
|