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|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
|