#!/usr/bin/env python3 """Read-only strict inventory of the observed Enfusion FORM/PAC1 PAK format. Names, declared sizes and verified content hashes only; never extract payloads. """ import argparse from dataclasses import dataclass import hashlib import json import os from pathlib import Path import re import stat import struct import unicodedata import zlib BLOCK = 1024 * 1024 class PakError(ValueError): pass @dataclass(frozen=True) class Limits: archive_bytes: int = 4 * 1024**3 tree_bytes: int = 32 * 1024**2 entries: int = 250000 # files plus directories depth: int = 64 entry_bytes: int = 1024**3 total_bytes: int = 8 * 1024**3 expansion_ratio: int = 1024 def validate(self): if any(type(value) is not int or value <= 0 for value in vars(self).values()): raise PakError('All limits must be positive integers') if self.depth > 128: raise PakError('Maximum supported tree depth is 128') def exact(stream, count): data = stream.read(count) if len(data) != count: raise PakError('Truncated archive') return data def component(raw): try: name = raw.decode('utf-8') except UnicodeDecodeError as error: raise PakError('Non-UTF-8 entry name') from error if (not name or name in {'.', '..'} or any(char in name for char in '/\\:') or any(ord(char) < 32 or ord(char) == 127 for char in name) or name.endswith((' ', '.')) or unicodedata.normalize('NFC', name) != name or re.fullmatch(r'(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?', name, re.I)): raise PakError('Unsafe or noncanonical entry name') return name def parse_tree(tree, data_start, data_end, limits): if len(tree) < 6 or tree[:2] != b'\0\0': raise PakError('Unsupported FILE root header') offset = 6 seen = set() entries = [] count_seen = 0 total = 0 def take(size): nonlocal offset if offset + size > len(tree): raise PakError('Truncated FILE entry') result = tree[offset:offset+size] offset += size return result def walk(parent, count, depth): nonlocal count_seen, total if depth > limits.depth or count > limits.entries - count_seen: raise PakError('Directory depth/count limit exceeded') for _ in range(count): count_seen += 1 if count_seen > limits.entries: raise PakError('Entry count limit exceeded') kind, length = take(2) name = component(take(length)) path = parent + name key = path.casefold() if key in seen: raise PakError('Duplicate or case-colliding file/directory path: '+path) seen.add(key) if kind == 0: children = struct.unpack(' data_end or stored > data_end-start: raise PakError('Payload outside DATA bounds: '+path) if flags == (0, 0) and stored != size: raise PakError('Raw payload stored/expanded size mismatch: '+path) if flags == (1, 6) and stored == 0: raise PakError('Empty compressed stream: '+path) if size > limits.entry_bytes or size > max(1, stored)*limits.expansion_ratio: raise PakError('Entry expansion/size limit exceeded: '+path) total += size if total > limits.total_bytes: raise PakError('Aggregate expanded-data limit exceeded') entries.append({'path': path, 'offset': start, 'stored_bytes': stored, 'content_bytes': size, 'compression': 'zlib' if flags[0] else 'raw', 'opaque_tail_hex': raw[20:24].hex()}) else: raise PakError('Unsupported FILE node kind') walk('', struct.unpack_from(' expected: raise PakError('Expanded data exceeds declared size: '+entry['path']) hasher.update(data) try: while remaining: data = exact(stream, min(BLOCK, remaining)) remaining -= len(data) if decoder is None: accept(data) continue while data: previous = data output = decoder.decompress(data, min(BLOCK, expected-produced+1)) accept(output) if decoder.unused_data or (decoder.eof and remaining): raise PakError('Trailing/concatenated compressed payload: '+entry['path']) data = decoder.unconsumed_tail if data == previous and not output: raise PakError('Compressed decoder made no progress') if decoder is not None: while not decoder.eof: output = decoder.decompress(b'', min(BLOCK, expected-produced+1)) if not output: break accept(output) if not decoder.eof or decoder.unused_data or decoder.unconsumed_tail: raise PakError('Incomplete/extra compressed payload: '+entry['path']) except zlib.error as error: raise PakError('Invalid zlib stream: '+entry['path']) from error if produced != expected: raise PakError('Expanded size mismatch: '+entry['path']) return hasher.hexdigest() def inventory(path, limits=Limits()): limits.validate() path = Path(path) descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) with os.fdopen(descriptor, 'rb') as stream: before = os.fstat(stream.fileno()) if not stat.S_ISREG(before.st_mode) or before.st_size > limits.archive_bytes: raise PakError('Input must be a regular archive within its size limit') header = exact(stream, 12) if header[:4] != b'FORM' or header[8:] != b'PAC1' or struct.unpack_from('>I', header, 4)[0] != before.st_size-8: raise PakError('Unsupported or inconsistent FORM/PAC1 header') offset = 12 chunks = {} for expected in (b'HEAD', b'DATA', b'FILE'): stream.seek(offset) tag, size = struct.unpack('>4sI', exact(stream, 8)) if tag != expected or size > before.st_size-offset-8: raise PakError('Unsupported chunk order or out-of-bounds chunk') chunks[tag] = (offset+8, size) offset += 8+size if offset != before.st_size: raise PakError('Unknown trailing archive chunks/bytes') start, size = chunks[b'HEAD'] if size != 28: raise PakError('Unsupported HEAD size') stream.seek(start) head = exact(stream, size) if head[:4] != b'\x03\x00\x01\x00' or head[24:] != b'\0'*4: raise PakError('Unsupported HEAD version/flags') start, size = chunks[b'FILE'] if size > limits.tree_bytes: raise PakError('FILE metadata exceeds size limit') stream.seek(start) tree = exact(stream, size) data_start, data_size = chunks[b'DATA'] entries, expanded, nodes = parse_tree(tree, data_start, data_start+data_size, limits) for entry in entries: entry['sha256'] = content_hash(stream, entry) stream.seek(0) archive_hash = hashlib.sha256() for block in iter(lambda: stream.read(BLOCK), b''): archive_hash.update(block) after = os.fstat(stream.fileno()) if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): raise PakError('Archive changed during inspection') return {'format': 'FORM/PAC1 observed HEAD 03000100', 'archive': str(path), 'archive_bytes': before.st_size, 'archive_sha256': archive_hash.hexdigest(), 'head_hex': head.hex(), 'entry_count': len(entries), 'tree_node_count': nodes, 'expanded_bytes': expanded, 'entries': sorted(entries, key=lambda row: row['path']), 'limits': vars(limits), 'structural_inventory_verified': True, 'platform_or_resource_semantics_verified': False, 'release_qualified': False, 'scope': 'Bounded structural/content-hash inspection only; no extraction, native load or gameplay proof'} def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('archive', type=Path) parser.add_argument('--output', type=Path, help='New JSON report only; never overwritten') args = parser.parse_args(argv) try: if args.output and (args.output.exists() or args.output.is_symlink()): raise PakError('Output already exists; refusing overwrite') report = inventory(args.archive) text = json.dumps(report, indent=2, sort_keys=True)+'\n' if args.output: with args.output.open('x') as output: output.write(text) else: print(text, end='') return 0 except (OSError, ValueError, struct.error) as error: print(json.dumps({'structural_inventory_verified': False, 'error': str(error), 'release_qualified': False})) return 1 if __name__ == '__main__': raise SystemExit(main())