Files
EscapistsReintroduced/tests/test_packed_core_game.py
T

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 Rereintroduced - 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 : Rereintroduced [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()