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 "Co10 Escape 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()
|