Files
EscapistsReintroduced/tools/packed_external.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
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.
2026-09-20 16:42:52 +02:00

187 lines
14 KiB
Python

"""Explicit v4 DEVELOPMENT combined-mod consumption; v1/v2 remain unchanged.
Only the audited installed external closure is linked, never copied or published.
"""
import hashlib
import json
from pathlib import Path
import re
import shutil
import time
import uuid
import external_addons as external
from project_schema import parse_project
from source_bundle import PROJECTS
FILES = {'addon.gproj','data.pak','resourceDatabase.rdb'}
MANIFEST = 'packed-input-provenance.json'
def require(condition, message):
if not condition: raise ValueError(message)
def digest(path):
require(path.is_file() and not path.is_symlink(),'Expected regular file: '+str(path))
before=path.stat(); h=hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda:stream.read(1024*1024),b''): h.update(block)
after=path.stat()
require((before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)==(after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns),'Input changed while hashing: '+str(path))
return h.hexdigest()
def inventory(folder):
folder=Path(folder)
require(folder.is_dir() and not folder.is_symlink() and folder==folder.resolve(),'Packed v4 closure must be a canonical real directory')
require({p.name for p in folder.iterdir()}==set(PROJECTS),'Packed v4 requires exactly the combined core addon folder')
records={}
for key,(name,guid,deps) in PROJECTS.items():
addon=folder/key
require(addon.is_dir() and not addon.is_symlink(),'Packed addon must be a real directory')
require({p.name for p in addon.iterdir()}==FILES,'Packed addon requires exactly three runtime files')
for filename in sorted(FILES):
path=addon/filename;sha=digest(path)
require(path.stat().st_size>0,'Empty packed input: '+str(path))
records[key+'/'+filename]={'bytes':path.stat().st_size,'sha256':sha}
descriptor=addon/'addon.gproj';text=descriptor.read_bytes().decode()
require(hashlib.sha256(text.encode()).hexdigest()==records[key+'/addon.gproj']['sha256'],'Project changed during parsing')
parsed=parse_project(text,allow_core_menu=key=='core')
actual=re.findall(r'"([A-F0-9]{16})"',parsed[4]) if parsed else []
expected={'58D0FB3206B6F859',*(PROJECTS[d][1] for d in deps),*external.direct_for(key)}
require(parsed and parsed[1]==name and parsed[2]==guid and set(actual)==expected and len(actual)==len(expected),'Packed v4 exact project identity/dependency mismatch: '+key)
return records
def request(args,root):
require(args.project=='chat' and getattr(args,'packed_addons',None) is not None and getattr(args,'packed_core',None) is None,'--external-addons requires --project chat --packed-addons (explicit packed v4)')
require(args.profile==Path('/tmp/erri-game-profile') and args.settle==15,'Packed v4 owns a new private profile and requires 15-second settling')
session=getattr(args,'packed_session',None)
require(session is not None,'Packed v4 requires --packed-session')
session=Path(session)
require(session.is_absolute() and session.parent==Path('/tmp') and session==session.resolve() and re.fullmatch(r'erri-packed-chat-[A-Za-z0-9_-]+',session.name) and not session.exists() and not session.is_symlink(),'Packed v4 requires a new canonical /tmp/erri-packed-chat-<name> session')
candidate=Path(args.packed_addons);artifacts=inventory(candidate)
policy_path=Path(root)/'docs/external-addons.json';policy_sha=digest(policy_path);rules=external.policy(root)
require(digest(policy_path)==policy_sha,'External policy changed during parsing')
cache={};source=Path(args.external_addons).resolve();records=external.installed(source,rules,cache)
names={}
for guid,record in records.items():
descriptor=Path(record['path'])/'addon.gproj';data=descriptor.read_bytes()
require(hashlib.sha256(data).hexdigest()==record['files']['addon.gproj']['sha256'],'External project changed while parsing its mount name')
match=re.search(r'\bID\s+"([A-Za-z0-9_-]+)"',data.decode())
require(match and match[1] not in {'core','ArmaReforger',*(p[0] for p in PROJECTS.values()),*names.values()},'Invalid/duplicate external project mount name')
names[guid]=match[1]
return {'schema_version':4,'project':'chat','candidate':candidate,'session':session,'input':session/'input/addons','manifest':artifacts,'external_source':source,'policy_path':policy_path,'policy_sha256':policy_sha,'policy':rules,'external_records':records,'external_names':names,'external_cache':cache}
def verify_external(packed,profile=None):
require(digest(packed['policy_path'])==packed['policy_sha256'],'External policy changed')
cache=packed['external_cache']
if 'cache_path' in packed:
require(digest(packed['cache_path'])==packed['cache_sha256'],'External fingerprint cache changed')
cache=json.loads(packed['cache_path'].read_text())
require(external.installed(packed['external_source'],packed['policy'],cache)==packed['external_records'],'External packed inputs changed')
if profile is not None:
require(profile.is_dir() and not profile.is_symlink() and profile==profile.resolve(),'Private runtime profile redirected')
addons=profile/'addons'
require(addons.is_dir() and not addons.is_symlink(),'Private dependency directory changed')
expected={'ArmaReforger',*('ERRI_'+g for g in packed['external_records'])}
require({p.name for p in addons.iterdir()}-{'saves'}==expected,'Missing/unrelated private addon link')
require(not (addons/'saves').is_symlink(),'Redirected private saves directory')
if (addons/'saves').exists():
require((addons/'saves').is_dir() and not any((addons/'saves').rglob('*.gproj')),'Unexpected addon in private saves')
vanilla=addons/'ArmaReforger'
require(vanilla.is_symlink() and vanilla.resolve()==packed['vanilla'].resolve(),'Private vanilla link changed')
require(external.verify_profile(packed['external_records'],packed['external_source'],packed['policy'],packed['cache_path'],addons),'External private profile changed')
return True
def prepare(packed):
verify_external(packed)
require(inventory(packed['candidate'])==packed['manifest'],'Packed candidate changed before copy')
packed['session'].mkdir(exist_ok=False);packed['input'].mkdir(parents=True,exist_ok=False)
for name in packed['manifest']:
target=packed['input']/name;target.parent.mkdir(parents=True,exist_ok=True)
with (packed['candidate']/name).open('rb') as src,target.open('xb') as dst:shutil.copyfileobj(src,dst)
require(inventory(packed['input'])==packed['manifest'] and inventory(packed['candidate'])==packed['manifest'],'Packed input changed while copying')
packed['cache_path']=packed['session']/'external-source-cache.json'
packed['cache_path'].write_text(json.dumps(packed['external_cache'],sort_keys=True))
packed['cache_sha256']=digest(packed['cache_path'])
(packed['session']/'external-addons-policy.json').write_text(json.dumps(packed['policy'],sort_keys=True))
(packed['session']/'external-addons-inputs.json').write_text(json.dumps(packed['external_records'],sort_keys=True))
def prepare_profile(packed,steam,profile):
verify_external(packed)
packed['vanilla']=steam/'steamapps/common/Arma Reforger/addons/data'
records=external.prepare_profile(profile/'addons',packed['external_source'],packed['policy'],packed['cache_path'])
require(records==packed['external_records'],'External inputs changed while preparing private links')
packed['cache_sha256']=digest(packed['cache_path'])
packed['profile']=profile
verify_external(packed,profile)
def snapshot(packed,command,profile):
verify_external(packed,profile)
require(inventory(packed['input'])==packed['manifest'] and inventory(packed['candidate'])==packed['manifest'],'Packed inputs changed before launch')
value={'schema_version':4,'kind':'packed-chat-external-runtime-input','project':'chat','addon_order':list(PROJECTS),'launch_id':str(uuid.uuid4()),'captured_unix':time.time(),'candidate_directory':str(packed['candidate']),'runtime_directory':str(packed['input']),'profile':str(profile),'command':list(command),'artifacts':packed['manifest'],'external_policy_sha256':packed['policy_sha256'],'external_policy':packed['policy'],'external_addons':packed['external_records'],'external_project_names':packed['external_names'],'build_qualification':'DEVELOPMENT unqualified candidate; original packaging verdict unchanged'}
raw=(json.dumps(value,indent=2)+'\n').encode();(packed['session']/MANIFEST).write_bytes(raw)
return raw
def seal(packed,raw,session):
value=json.loads(raw);unchanged=False;error=None
try:
require(value['schema_version']==4 and value['kind']=='packed-chat-external-runtime-input','Wrong packed v4 provenance schema')
require(value['external_addons']==packed['external_records'] and value['external_policy_sha256']==packed['policy_sha256'] and value['external_policy']==packed['policy'] and value['external_project_names']==packed['external_names'],'External provenance mismatch')
verify_external(packed,packed['profile'])
require(inventory(packed['input'])==value['artifacts'] and inventory(packed['candidate'])==value['artifacts'],'Packed inputs changed after launch')
require((packed['session']/MANIFEST).read_bytes()==raw,'Session packed provenance changed')
if session:require((session/MANIFEST).read_bytes()==raw,'Native session packed provenance changed')
unchanged=True
except (OSError,ValueError,KeyError) as caught:error=str(caught)
console=session/'console.log' if session else None
return {'packed_only':True,'packed_schema_version':4,'packed_input_provenance':MANIFEST,'packed_input_provenance_sha256':hashlib.sha256(raw).hexdigest(),'packed_launch_id':value['launch_id'],'stopped_unix':time.time(),'packed_inputs_unchanged_after_run':unchanged,'packed_external_inputs_unchanged_after_run':unchanged,'packed_input_error':error,'external_addon_count':len(packed['external_records']),'console_sha256':digest(console) if console and console.is_file() else None,'build_qualified':False,'release_qualified':False}
def evidence(content,packed,steam,profile,legacy):
report=legacy.game_evidence(content,'chat');game=steam/'steamapps/common/Arma Reforger'
def normalized(path):return legacy.native_path(path,game)
# Permit only each exact private alias and its captured canonical target, never
# an arbitrary workshop path or generic symlink/path normalization.
paths={};mounts={};counts={};databases={}
def add(guid,name,folders,project_file,pak_count=None):
paths[guid]={str(p/project_file) for p in folders}
mounts[name]={str(p) for p in folders};databases[name]={str(p/'resourceDatabase.rdb') for p in folders};counts[name]=pak_count
add('5614BBCCBB55ED1C','core',[game/'addons/core'],'core.gproj')
add('58D0FB3206B6F859','ArmaReforger',[game/'addons/data',profile/'addons/ArmaReforger'],'ArmaReforger.gproj')
for key,(name,guid,_) in PROJECTS.items():add(guid,name,[packed['input']/key],'addon.gproj',1)
for guid,record in packed['external_records'].items():
add(guid,packed['external_names'][guid],[profile/'addons'/('ERRI_'+guid),Path(record['path'])],'addon.gproj',sum(name.endswith('.pak') for name in record['files']))
violations=[];complete=False
for block in re.findall(r"Loaded addons:\r?\n((?:[^\n]*ENGINE[^\n]*gproj:[^\n]*\n)+)",content):
entries=re.findall(r"gproj: '([^']+)' guid: '([0-9A-Fa-f]{16})'",block);seen=set()
if len(entries)!=len(block.splitlines()):violations.append('Malformed loaded-addon entry')
for path,guid in entries:
guid=guid.upper()
if guid in seen or guid not in paths or normalized(path) not in paths.get(guid,set()):violations.append('Unexpected/redirected/duplicate loaded addon: '+path+' '+guid)
seen.add(guid)
complete=seen==set(paths)
mounted=set()
for path,count,name in re.findall(r"FileSystem: Adding package '([^']+)' \(pak count: (\d+)\) to filesystem under name ([^\s]+)",content):
if name not in mounts or normalized(path) not in mounts.get(name,set()) or int(count)<1 or (counts.get(name) is not None and int(count)!=counts[name]):violations.append('Unexpected packed mount/count: '+name+' '+path)
else:mounted.add(name)
for path,name in re.findall(r"FileSystem: Adding relative directory '([^']+)' to filesystem under name ([^\s]+)",content):
resolved=normalized(path)
if name=='profile' and resolved==str(profile/'profile'):continue
if name=='logs' and re.fullmatch(re.escape(str(profile/'logs'))+r'/logs_[0-9_-]+',resolved):continue
violations.append('Unexpected loose directory mount: '+name+' '+path)
allowed_dirs={str(packed['input']),str(game/'addons'),str(profile/'addons')}
dirs=re.findall(r"ENGINE\s*:\s*dir: '([^']+)'",content)
for path in dirs:
resolved=str(profile/'addons') if path=='$profile:../addons/' else normalized(path)
if resolved not in allowed_dirs:violations.append('Unexpected addon search directory: '+path)
loaded_databases=set()
for name,path in re.findall(r'ResourceDB: loading cache \(id=\d+ name=([^\s]+) path=([^\r\n]+)\)',content):
resolved=normalized(path)
if name=='profile' and resolved==str(profile/'profile/resourceDatabase.rdb'):continue
if name=='logs' and re.fullmatch(re.escape(str(profile/'logs'))+r'/logs_[0-9_-]+/resourceDatabase\.rdb',resolved):continue
if name not in databases or resolved not in databases.get(name,set()):violations.append('Unexpected resource database: '+name+' '+path)
else:loaded_databases.add(name)
modules=re.findall(r'Module: (GameLib|Game);[^\n]*defines:\s*"([^"]*)"',content)
normal={name for name,_ in modules}=={'GameLib','Game'} and all(not {'WORKBENCH','ENF_WB'}.intersection(flags.split(',')) for _,flags in modules)
if any({'WORKBENCH','ENF_WB'}.intersection(flags.split(',')) for _,flags in modules):violations.append('Workbench defines in packed ordinary runtime')
path_proof=complete and mounted==set(mounts) and loaded_databases==set(databases) and bool(dirs)
failed=report['failed'] or bool(violations)
return {**report,'packed_only':True,'packed_schema_version':4,'external_addon_count':len(packed['external_records']),'packed_loaded_paths_verified':path_proof,'packed_input_violations':violations,'packed_normal_modules':normal,'failed':failed,'ready':report['ready'] and path_proof and normal and not failed,'build_qualified':False,'release_qualified':False,'scope':'DEVELOPMENT packed combined mod plus exact audited external closure; ordinary local startup only, no clean-build, human escape, multiplayer, FPS or release qualification'}