Files
EscapistsReintroduced/tools/external_addons.py
T

124 lines
7.1 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','65930CB4CD0237B2')
ALLOWED={
'65930CB4CD0237B2':{BASE},
'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|preview)[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|preview)[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