Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
124 lines
7.1 KiB
Python
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[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
|