Files
EscapistsReintroduced/tools/external_addons.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

123 lines
7.0 KiB
Python

"""Explicit authorized external dependency policy and private native-profile exposure.
Never copy third-party assets or expose the whole workshop tree to Workbench.
"""
from pathlib import Path, PurePosixPath
import hashlib,json,re,stat
BASE='58D0FB3206B6F859'
DIRECT=('595F2BF2F44836FB','629B2BA37EFFD577','5E524E4FEECCA92B')
ALLOWED={
'595F2BF2F44836FB':{BASE,'1337C0DE5DABBEEF','BADC0DEDABBEDA5E'},
'1337C0DE5DABBEEF':{BASE},'BADC0DEDABBEDA5E':{BASE},
'629B2BA37EFFD577':{BASE},'5E389BB9F58B79A6':{BASE,'629B2BA37EFFD577'},
'5E524E4FEECCA92B':{BASE,'5E389BB9F58B79A6','629B2BA37EFFD577'},
}
def require(value,message):
if not value:raise ValueError(message)
def parse_project(text):
guid=re.search(r'\bGUID\s+"([A-F0-9]{16})"',text)
deps=re.search(r'\bDependencies\s*\{([^{}]*)\}',text)
require(guid and deps,'Missing addon identity/dependencies')
values=re.findall(r'"([A-F0-9]{16})"',deps[1]);require(len(values)==len(set(values)),'Duplicate dependency')
return guid[1],set(values)
def policy(root):
value=json.loads((Path(root)/'docs/external-addons.json').read_text())
require(value.get('schema_version')==1 and set(value['addons'])==set(ALLOWED),'Unreviewed external addon policy')
require(set(value['direct_ai_dependencies'])==set(DIRECT),'Unreviewed direct AI dependencies')
for guid,entry in value['addons'].items():
require(set(entry['dependencies'])==ALLOWED[guid],'Unreviewed transitive dependency: '+guid)
require(re.fullmatch(r'[a-f0-9]{64}',entry['project_sha256']) is not None,'Missing manifest fingerprint')
for path,entry in value['resources'].items():
require(not PurePosixPath(path).is_absolute() and '..' not in PurePosixPath(path).parts and '\\' not in path,'Unsafe resource path')
require(entry['addon'] in ALLOWED and re.fullmatch(r'[A-F0-9]{16}',entry['guid']) is not None,'Unknown resource owner/identity')
return value
def direct_for(project):return set(DIRECT) if project=='core' else set()
def resource_allowed(value,visible,guid,path):
entry=value['resources'].get(path)
return bool(entry and entry['guid']==guid and entry['addon'] in visible)
def fingerprint(path,cache):
path=Path(path);before=path.stat();require(path.is_file() and not path.is_symlink(),'External input must be a regular file: '+str(path))
key=str(path);stamp=[before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns]
old=cache.get(key)
if old and old.get('stat')==stamp:return old
digest=hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda:stream.read(4*1024*1024),b''):digest.update(block)
after=path.stat();require(stamp==[after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns],'External file changed while hashing')
result={'stat':stamp,'sha256':digest.hexdigest()};cache[key]=result;return result
def packed_input_files(folder):
# Installed workshop inputs are flat packed addons. A linked folder must not
# silently expose loose Scripts/Prefabs or resources outside this fingerprint.
files=[]
for path in sorted(Path(folder).iterdir()):
require(path.is_file() and not path.is_symlink(),'External packed input must be a flat regular file: '+str(path))
name=path.name
runtime=name in {'addon.gproj','resourceDatabase.rdb'} or re.fullmatch(r'[A-Za-z0-9_.-]+\.pak',name)
media=name=='thumbnail.png' or re.fullmatch(r'gallery[0-9]+_[0-9]+x[0-9]+\.(?:jpg|png)',name)
sidecar=re.fullmatch(r'(?:addon\.gproj|resourceDatabase\.rdb|[A-Za-z0-9_.-]+\.pak|thumbnail\.png|gallery[0-9]+_[0-9]+x[0-9]+\.(?:jpg|png))_[0-9]+(?:\.[0-9]+)*_manifest\.json',name)
require(runtime or media or sidecar or name in {'meta','ServerData.json'},'Unreviewed external packed input: '+str(path))
files.append(path)
require(any(path.suffix=='.pak' for path in files),'Missing runtime PAK: '+str(folder))
return files
def installed(source,value,cache=None):
source=Path(source).resolve();require(source.is_dir(),'External addon source directory missing')
cache={} if cache is None else cache
selected={}
for folder in source.iterdir():
if not folder.is_dir() or folder.is_symlink():continue
manifest=folder/'addon.gproj'
if not manifest.is_file() or manifest.is_symlink():continue
text=manifest.read_text();match=re.search(r'\bGUID\s+"([A-F0-9]{16})"',text)
if not match or match[1] not in ALLOWED:continue
guid=match[1]
require(guid not in selected,'Duplicate installed authorized addon: '+guid)
selected[guid]=folder
require(set(selected)==set(ALLOWED),'Missing installed authorized external addons: '+','.join(sorted(set(ALLOWED)-set(selected))))
records={}
for guid,folder in sorted(selected.items()):
entry=value['addons'][guid];manifest=folder/'addon.gproj';actual,deps=parse_project(manifest.read_text())
require(actual==guid and deps==ALLOWED[guid],'Installed dependency closure changed: '+guid)
require(hashlib.sha256(manifest.read_bytes()).hexdigest()==entry['project_sha256'],'Installed project manifest changed; audit update required: '+guid)
database=folder/'resourceDatabase.rdb';require(database.is_file() and not database.is_symlink(),'Missing installed resource database: '+guid)
data=database.read_bytes()
for path,resource in value['resources'].items():
if resource['addon']!=guid:continue
# Observed RDB resource record: NUL-terminated path, six-byte type header,
# then little-endian 64-bit resource identity. Bind both name and exact GUID.
needle=path.encode()+b'\0'+b'\x06\0\0\0\0\0'+bytes.fromhex(resource['guid'])[::-1]
require(data.count(needle)==1,'Installed resource identity missing/ambiguous: '+path)
files=packed_input_files(folder)
records[guid]={'path':str(folder),'title':entry['title'],'version':entry['version'],'files':{p.name:fingerprint(p,cache) for p in files}}
return records
def prepare_profile(addons,source,value,cache_path):
addons=Path(addons);cache_path=Path(cache_path)
cache=json.loads(cache_path.read_text()) if cache_path.is_file() else {}
records=installed(source,value,cache)
names={'ERRI_'+guid for guid in ALLOWED}
require(all(p.name in names|{'ArmaReforger','saves'} for p in addons.iterdir()),'Unrelated addon in private external profile')
for guid,entry in records.items():
link=addons/('ERRI_'+guid);target=Path(entry['path'])
if link.exists() or link.is_symlink():require(link.is_symlink() and link.resolve()==target,'Unexpected private dependency link: '+str(link))
else:link.symlink_to(target,target_is_directory=True)
cache_path.write_text(json.dumps(cache,indent=2)+'\n')
return records
def verify_profile(records,source,value,cache_path,addons):
addons=Path(addons)
require(addons.is_dir() and not addons.is_symlink(),'Private external addon directory changed')
expected={'ERRI_'+guid for guid in records}
require(all(path.name in expected|{'ArmaReforger','saves'} for path in addons.iterdir()),'Unrelated addon in private external profile')
for guid,entry in records.items():
link=addons/('ERRI_'+guid)
require(link.is_symlink() and link.resolve()==Path(entry['path']),'Private external dependency link changed: '+str(link))
cache=json.loads(Path(cache_path).read_text())
return installed(source,value,cache)==records