"""Offline static include-reference study; Python 3 standard library only. Usage: python inspect-ui-relationships.py CLIENT.zip > ui-relationships.json Never extracts, executes, renders, edits, or fetches client resources. Scope: fixed roots in uifiles/default and uiresources/default; XML Include text and HTML include-body[file]. These engine-specific literal includes are not a complete runtime dependency model. """ import collections import datetime import hashlib from html.parser import HTMLParser import json from pathlib import Path import posixpath import re import sys from urllib.parse import unquote, urlsplit import xml.etree.ElementTree as ET import zipfile SCOPES = ('uifiles/default', 'uiresources/default') ROOTS = {'EQUI.xml': 'uifiles/default/EQUI.xml', **{name: 'uiresources/default/windows/' + name for name in ('InventoryWindow.html', 'RespawnWnd.html', 'CastingWindow.html', 'ClaimWindow.html')}} PER_FILE = 2 * 1024 * 1024 TOTAL_BYTES = 32 * 1024 * 1024 MAX_NODES = 512 MAX_EDGES = 2048 SAFE = re.compile(r'[A-Za-z0-9_./ -]{1,180}') class Includes(HTMLParser): def __init__(self): super().__init__() self.references = [] self.has_base = False def handle_starttag(self, tag, attrs): values = dict(attrs) if tag == 'base' and values.get('href'): self.has_base = True if tag == 'include-body' and values.get('file'): self.references.append(values['file']) def resolve_reference(source, reference, index): try: uri = urlsplit(reference) except ValueError: return {'status': 'unsupported'} if uri.scheme or uri.netloc: return {'status': 'external-not-followed'} if not uri.path: return {'status': 'fragment-or-empty'} path = unquote(uri.path) if not SAFE.fullmatch(path) or path.startswith('/') or '\\' in path: return {'status': 'unsupported'} target = posixpath.normpath(posixpath.join(posixpath.dirname(source), path)) source_scope = next((scope for scope in SCOPES if source.startswith(scope + '/')), None) if target == '..' or target.startswith('../') or (source_scope and not target.startswith(source_scope + '/')): return {'status': 'outside-scope'} if target in index: return {'target': target, 'status': 'exact' if len(index[target]) == 1 else 'ambiguous'} folded = [name for name in index if name.casefold() == target.casefold()] if len(folded) == 1 and len(index[folded[0]]) == 1: return {'target': folded[0], 'status': 'case-only'} if folded: return {'target': target, 'status': 'ambiguous'} return {'target': target, 'status': 'unmatched'} def analyze(archive): index = collections.defaultdict(list) for entry in archive.infolist(): normalized = '/' + entry.filename.replace('\\', '/') if entry.is_dir(): continue for scope in SCOPES: marker = '/' + scope + '/' offset = normalized.lower().find(marker) if offset < 0: continue logical = scope + '/' + normalized[offset + len(marker):] if SAFE.fullmatch(logical) and '..' not in logical.split('/'): index[logical].append(entry) nodes, edges, budget, truncated = {}, [], 0, False queue = collections.deque(name for name in ROOTS.values() if len(index.get(name, [])) == 1) while queue: name = queue.popleft() if name in nodes: continue if len(nodes) >= MAX_NODES: truncated = True break entry = index[name][0] node = {'name': name, 'bytes': entry.file_size} nodes[name] = node if entry.file_size > PER_FILE or budget + entry.file_size > TOTAL_BYTES: node['parseStatus'] = 'byte-limit' continue with archive.open(entry) as stream: data = stream.read(PER_FILE + 1) if len(data) > PER_FILE: raise ValueError('Member exceeds declared inspection limit.') budget += len(data) node['sha256'] = hashlib.sha256(data).hexdigest() references = [] if name.lower().endswith('.html'): parser = Includes() parser.feed(data.decode('utf-8', errors='replace')) if parser.has_base: node['parseStatus'] = 'base-element-not-modeled' continue references = parser.references node['parseStatus'] = 'html-includes-parsed' elif name.lower().endswith('.xml'): if b'= MAX_EDGES: truncated = True break edge = {'source': name, **resolve_reference(name, reference, index)} edges.append(edge) if edge['status'] in ('exact', 'case-only'): queue.append(edge['target']) adjacency = collections.defaultdict(list) for edge in edges: adjacency[edge['source']].append(edge) summaries = [] for label, name in ROOTS.items(): if name not in nodes: summaries.append({'root': label, 'rootMember': name, 'rootMatches': len(index.get(name, [])), 'status': 'not-inspected'}) continue reachable, pending = set(), [name] while pending: current = pending.pop() if current in reachable: continue reachable.add(current) pending.extend(edge['target'] for edge in adjacency[current] if edge['status'] in ('exact', 'case-only') and edge['target'] in nodes) selected_edges = [edge for edge in edges if edge['source'] in reachable] confirmed = [edge for edge in selected_edges if edge['status'] in ('exact', 'case-only')] subset = set(reachable) omitted = next((edge['target'] for edge in confirmed if edge['target'] != name), None) available = lambda names: sum(edge['source'] in names and edge['target'] in names for edge in confirmed) limitations = [n for n in sorted(reachable) if nodes[n]['parseStatus'] not in ('html-includes-parsed', 'xml-includes-parsed')] summaries.append({'root': label, 'rootMember': name, 'rootMatches': 1, 'files': len(reachable), 'uncompressedBytes': sum(nodes[n]['bytes'] for n in reachable), 'edges': len(selected_edges), 'edgeStatuses': dict(collections.Counter(e['status'] for e in selected_edges)), 'unparsedNodes': limitations, 'nodes': sorted(reachable), 'availabilityMasks': {'fullArchiveMatchedEdges': len(confirmed), 'rootOnlyMatchedEdges': available({name}), 'resolvedSubsetMatchedEdges': available(subset), 'omitOneTarget': None if not omitted else {'removed': omitted, 'matchedEdges': available(subset - {omitted}), 'lostEdges': len(confirmed) - available(subset - {omitted})}}}) return {'methodVersion': '1.1', 'scope': list(SCOPES), 'referenceForms': ['XML Include element text', 'HTML include-body file attribute'], 'resolutionModel': 'literal relative paths within default UI; case preserved; no engine execution', 'limits': {'memberBytes': PER_FILE, 'totalReadBytes': TOTAL_BYTES, 'nodes': MAX_NODES, 'edges': MAX_EDGES}, 'readBytes': budget, 'traversalTruncated': truncated, 'roots': summaries, 'nodes': sorted(nodes.values(), key=lambda node: node['name']), 'edges': edges, 'binariesExecuted': False, 'networkConnections': False, 'controls': 'Availability masks alter only membership in the recorded graph; they are not runtime experiments.'} def inspect(path): digest = hashlib.sha256() with open(path, 'rb') as stream: for block in iter(lambda: stream.read(1024 * 1024), b''): digest.update(block) with zipfile.ZipFile(path) as archive: result = analyze(archive) return {'schemaVersion': 1, 'inspectedOn': datetime.datetime.now(datetime.timezone.utc).date().isoformat(), 'zipSHA256': digest.hexdigest(), 'zipBytes': Path(path).stat().st_size, **result} if __name__ == '__main__': if len(sys.argv) != 2: raise SystemExit('Usage: python inspect-ui-relationships.py CLIENT.zip > ui-relationships.json') print(json.dumps(inspect(sys.argv[1]), indent=2))