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.
187 lines
14 KiB
Python
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'}
|