Validate independent packed addons and complete physical mortar salvos
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archive a stopped packed-only Core or Chat run without attaching loose-source identity."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import headless_game
|
||||
from headless_game import PACKED_FILES, PACKED_MANIFEST, command_for, packed_evidence, packed_inventory
|
||||
from native_provenance import encoded, strict_json
|
||||
from native_report import analyze
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADDON_ORDER = ['core', 'ai', 'intel', 'map', 'chat']
|
||||
RESULTS = {'packed-core-started', 'packed-evidence-incomplete', 'packed-input-changed',
|
||||
'cleanup-failed', 'unexpected-multiple-sessions', 'process-exited',
|
||||
'interrupted', 'runner-failed', 'game-failed', 'timeout'}
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def read(path):
|
||||
require(path == path.resolve() and path.is_file() and not path.is_symlink(), 'Missing, noncanonical or symlinked evidence: '+str(path))
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
return type(value) in (int, float) and math.isfinite(value) and value > 0
|
||||
|
||||
|
||||
|
||||
def schema(metadata):
|
||||
fields = {'schema_version', 'kind', 'launch_id', 'captured_unix', 'candidate_directory', 'runtime_directory', 'profile', 'command', 'artifacts', 'build_qualification'}
|
||||
require(isinstance(metadata, dict), 'Invalid packed launch metadata object')
|
||||
version = metadata.get('schema_version')
|
||||
require(type(version) is int, 'Invalid packed schema version')
|
||||
if version == 1:
|
||||
require(set(metadata) == fields and metadata.get('kind') == 'packed-core-runtime-input', 'Invalid packed Core metadata fields/kind')
|
||||
return 'core'
|
||||
require(version == 2 and set(metadata) == fields | {'project', 'addon_order'}
|
||||
and metadata.get('kind') == 'packed-chat-runtime-input' and metadata.get('project') == 'chat'
|
||||
and metadata.get('addon_order') == ADDON_ORDER, 'Invalid packed Chat metadata fields/kind/dependency order')
|
||||
return 'chat'
|
||||
|
||||
|
||||
def payload_inventory(folder, project):
|
||||
# Preserve the original Core schema and exact-three-file validation. Chat
|
||||
# uses the runner's exact-five-addon closure validator, not an arbitrary tree.
|
||||
if project == 'core':
|
||||
return packed_inventory(folder)
|
||||
return headless_game.packed_payload_inventory(folder, project)
|
||||
|
||||
def inspect(session):
|
||||
"""Read and verify first; no writes and no engine/process operation."""
|
||||
session = Path(session)
|
||||
require(session.is_absolute() and session == session.resolve() and session.is_dir(), 'Use the canonical stopped packed session directory')
|
||||
raw = read(session/PACKED_MANIFEST)
|
||||
metadata = strict_json(raw)
|
||||
project = schema(metadata)
|
||||
expected_result = 'packed-'+project+'-started'
|
||||
require(metadata['build_qualification'] == 'unqualified candidate; packaging verdict is unchanged', 'Packed metadata cannot assert build qualification')
|
||||
require(isinstance(metadata['launch_id'], str), 'Invalid packed launch ID')
|
||||
try:
|
||||
uuid.UUID(metadata['launch_id'])
|
||||
except ValueError as error:
|
||||
raise ValueError('Invalid packed launch UUID') from error
|
||||
runtime = session/'input/addons/core' if project == 'core' else session/'input/addons'
|
||||
profile = session/'profile'
|
||||
require(metadata['runtime_directory'] == str(runtime) and metadata['profile'] == str(profile), 'Packed runtime/profile paths do not belong to this session')
|
||||
candidate = metadata['candidate_directory']
|
||||
require(isinstance(candidate, str) and Path(candidate).is_absolute() and '..' not in Path(candidate).parts, 'Invalid recorded candidate path')
|
||||
artifacts = metadata['artifacts']
|
||||
expected_files = PACKED_FILES if project == 'core' else {addon+'/'+name for addon in ADDON_ORDER for name in PACKED_FILES}
|
||||
require(isinstance(artifacts, dict) and set(artifacts) == expected_files, 'Packed provenance must hash the exact complete runtime artifact set')
|
||||
for name, info in artifacts.items():
|
||||
require(isinstance(info, dict) and set(info) == {'bytes', 'sha256'} and type(info['bytes']) is int and info['bytes'] > 0
|
||||
and isinstance(info['sha256'], str) and re.fullmatch('[0-9a-f]{64}', info['sha256']), 'Invalid packed artifact hash/size: '+name)
|
||||
require(payload_inventory(runtime, project) == artifacts, 'Runtime package bytes differ from launch manifest; refusing replacement payload')
|
||||
runner_raw = read(session/'packed-runner.json')
|
||||
runner = strict_json(runner_raw)
|
||||
require(isinstance(runner, dict), 'Invalid stopped runner report')
|
||||
require(runner.get('packed_only') is True and runner.get('project') == project and runner.get('dedicated_server') is False and runner.get('control_only') is False, 'Runner is not the recorded packed-only ordinary project')
|
||||
require(not any(key in runner for key in ('source_provenance', 'launch_source_sha256', 'launch_source_manifest', 'launch_source_metadata', 'source_unchanged_after_run', 'end_source_sha256')), 'Packed run cannot carry loose-source qualification')
|
||||
require(runner.get('build_qualified') is False and runner.get('release_qualified') is False, 'Packed consumption cannot qualify a build or release')
|
||||
require(runner.get('packed_input_provenance') == PACKED_MANIFEST and runner.get('packed_input_provenance_sha256') == digest(raw) and runner.get('packed_launch_id') == metadata['launch_id'], 'Runner packed manifest/launch binding differs')
|
||||
require(runner.get('cleanup_confirmed') is True, 'Archive only after owned cleanup is confirmed')
|
||||
require('packed_input_error' in runner and (runner['packed_input_error'] is None or isinstance(runner['packed_input_error'], str)), 'Missing/malformed packed input diagnostic')
|
||||
require(type(runner.get('packed_inputs_unchanged_after_run')) is bool and type(runner.get('packed_consumption_verified')) is bool, 'Missing packed input/consumption verdicts')
|
||||
require(type(runner.get('failure_observed_during_polling')) is bool, 'Missing polling failure latch')
|
||||
allowed_results = (RESULTS - {'packed-core-started'}) | {expected_result}
|
||||
require(isinstance(runner.get('result'), str) and runner['result'] in allowed_results, 'Unknown packed runner result')
|
||||
require(timestamp(metadata['captured_unix']) and timestamp(runner.get('started_unix')) and timestamp(runner.get('stopped_unix'))
|
||||
and metadata['captured_unix'] <= runner['started_unix'] <= runner['stopped_unix'], 'Invalid snapshot/start/stop chronology')
|
||||
command = metadata['command']
|
||||
require(isinstance(command, list) and len(command) > 16 and all(isinstance(arg, str) for arg in command), 'Invalid recorded native command')
|
||||
executable = runner.get('executable')
|
||||
require(isinstance(executable, str) and Path(executable).is_absolute(), 'Invalid recorded executable')
|
||||
exe = Path(executable)
|
||||
require(exe.parts[-4:] == ('steamapps', 'common', 'Arma Reforger', 'ArmaReforgerSteam.exe'), 'Recorded executable is not installed ordinary Arma Reforger')
|
||||
steam = exe.parents[3]
|
||||
packed_context = {'input': runtime, 'project': project}
|
||||
expected = command_for(Path('/unused-source'), steam, Path('/unused-prefix'), profile, project, packed_context)
|
||||
require(command == expected, 'Recorded command differs from exact packed-only ordinary launch; unexpected flags/search roots')
|
||||
native = runner.get('session')
|
||||
require(isinstance(native, str), 'No sealed native log session was produced')
|
||||
native = Path(native)
|
||||
require(native.parent == profile/'logs' and re.fullmatch(r'logs_[A-Za-z0-9_-]+', native.name) and native == native.resolve(), 'Native log session escapes the recorded private profile')
|
||||
require(read(native/PACKED_MANIFEST) == raw and read(native/'runner.json') == runner_raw, 'Native and root packed manifest/runner copies disagree')
|
||||
startup_raw = read(session/'packed-startup-report.json')
|
||||
require(read(native/'game-startup-report.json') == startup_raw, 'Native and root raw startup reports disagree')
|
||||
require(isinstance(strict_json(startup_raw), dict), 'Invalid raw startup report')
|
||||
console = read(native/'console.log')
|
||||
require(runner.get('console_sha256') == digest(console), 'Raw console differs from stopped runner seal')
|
||||
files = {PACKED_MANIFEST: raw, 'runner.json.txt': runner_raw, 'raw-startup-report.json': startup_raw, 'console.log.txt': console}
|
||||
auxiliary = {}
|
||||
for name in ('script.log', 'error.log', 'crash.log', 'headless-launch.log', 'rereintroduced-hq.log'):
|
||||
if (native/name).exists() or (native/name).is_symlink():
|
||||
data = read(native/name)
|
||||
files[name+'.txt'] = data
|
||||
auxiliary[name] = analyze(data.decode(errors='replace'), name)
|
||||
for name in sorted(expected_files):
|
||||
data = read(runtime/name)
|
||||
require(len(data) == artifacts[name]['bytes'] and digest(data) == artifacts[name]['sha256'], 'Packed artifact changed during archive read: '+name)
|
||||
files['package/'+name] = data
|
||||
# Recompute from the console, not the saved ready flag; auxiliary errors also
|
||||
# remain failures. Warnings in error.log are not mislabeled as native errors.
|
||||
evidence = packed_evidence(console.decode(errors='replace'), packed_context, steam, profile)
|
||||
auxiliary_failed = any(report['engine_errors'] or report['script_or_runtime_failures'] or report['missing_end_screen'] for report in auxiliary.values())
|
||||
settled_claim = (runner['result'] == expected_result and runner['stopped_unix'] - runner['started_unix'] >= 15)
|
||||
passed = (evidence['ready'] is True and not auxiliary_failed and settled_claim
|
||||
and runner['packed_inputs_unchanged_after_run'] is True and runner.get('packed_input_error') is None
|
||||
and runner['packed_consumption_verified'] is True and runner['failure_observed_during_polling'] is False and runner.get('runner_error') is None)
|
||||
report = {**evidence, 'packed_consumption_verified': passed, 'archived_packed_input_verified': True,
|
||||
'runner_result': runner['result'], 'runner_failure_latched': runner['failure_observed_during_polling'],
|
||||
'packed_inputs_unchanged_after_run': runner['packed_inputs_unchanged_after_run'],
|
||||
'auxiliary_native_reports': auxiliary, 'auxiliary_native_failure': auxiliary_failed,
|
||||
'runner_settle_claim_consistent': settled_claim,
|
||||
'settle_evidence': 'Native readiness recomputed; 15-second continuous settling is the sealed runner verdict, not an independently recorded polling timeline.',
|
||||
'build_qualified': False, 'release_qualified': False,
|
||||
'qualification': 'packed-only ordinary startup consumption only; original package build verdict and current source acceptance remain separate'}
|
||||
report['failed'] = not passed
|
||||
report['ready'] = passed
|
||||
report['verifier_sha256'] = {name: digest(read(Path(__file__).resolve().parent/name)) for name in ('archive_packed.py', 'headless_game.py', 'native_report.py')}
|
||||
files['report.json'] = encoded(report)
|
||||
archive_kind = {'schema_version': metadata['schema_version'], 'kind': 'packed-'+project+'-consumption', 'packed_launch_id': metadata['launch_id'],
|
||||
'build_qualified': False, 'release_qualified': False}
|
||||
if project == 'chat':
|
||||
archive_kind.update(project=project, addon_order=ADDON_ORDER)
|
||||
files['archive-kind.json'] = encoded(archive_kind)
|
||||
return files, report
|
||||
|
||||
|
||||
def archive(session, name, root=ROOT):
|
||||
require(isinstance(name, str) and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}', name) is not None, 'Archive name must be one safe ASCII component')
|
||||
root = Path(root)
|
||||
evidence = root/'docs/evidence'
|
||||
require(root == root.resolve() and evidence == evidence.resolve(), 'Evidence destination must not redirect through symlinks')
|
||||
destination = evidence/name
|
||||
require(not destination.exists() and not destination.is_symlink(), 'Evidence already exists; refusing overwrite')
|
||||
files, report = inspect(session)
|
||||
# No mutation until every provenance binding and artifact byte has passed.
|
||||
destination.mkdir(parents=True, exist_ok=False)
|
||||
for relative, data in files.items():
|
||||
target = destination/relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open('xb') as output:
|
||||
output.write(data)
|
||||
hashes = {name: digest(raw) for name, raw in sorted(files.items())}
|
||||
with (destination/'archive-sha256.json').open('xb') as output:
|
||||
output.write(encoded(hashes))
|
||||
metadata = strict_json(files[PACKED_MANIFEST])
|
||||
require(payload_inventory(destination/'package', schema(metadata)) == metadata['artifacts'], 'Archived packed payload verification failed')
|
||||
return destination, 0 if report['packed_consumption_verified'] else 1
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('session', type=Path, help='Stopped /tmp/erri-packed-core-* or erri-packed-chat-* session root, not its logs subdirectory')
|
||||
parser.add_argument('name', help='New docs/evidence directory name; never overwritten')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
destination, status = archive(args.session, args.name)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.error(str(error))
|
||||
print(f'Archived {destination}; packed consumption exit={status} (nonzero retains failed evidence)')
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -59,7 +59,7 @@ def check():
|
||||
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
|
||||
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
|
||||
for name in ['test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
for name in ['test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
|
||||
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
|
||||
if __name__=='__main__':check()
|
||||
|
||||
+271
-15
@@ -4,6 +4,7 @@ No Workbench plugin, scripted smoke fixture, Steam profile, or existing process
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,227 @@ def refuse(message):
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
|
||||
PACKED_FILES = {'addon.gproj', 'data.pak', 'resourceDatabase.rdb'}
|
||||
PACKED_GUID = '2528466DE220D91C'
|
||||
PACKED_MANIFEST = 'packed-input-provenance.json'
|
||||
PACKED_BASE = '58D0FB3206B6F859'
|
||||
PACKED_PROJECTS = {
|
||||
'core': ('ERR_Core', PACKED_GUID, ()),
|
||||
'ai': ('ERR_Ai', '5192138CB9D5B911', ('core',)),
|
||||
'intel': ('ERR_Intel', 'D31039C7C53E348A', ('core',)),
|
||||
'map': ('ERR_Map', '9AAEE9041D79809E', ('core',)),
|
||||
'chat': ('ERR_Chat', 'FA804F411BDDF2C6', ('core', 'ai', 'intel', 'map')),
|
||||
}
|
||||
|
||||
|
||||
def packed_inventory(folder, project='core'):
|
||||
"""Exact runtime payload, including files deliberately excluded by source manifests."""
|
||||
if project not in PACKED_PROJECTS:
|
||||
refuse('Unknown packed addon project')
|
||||
folder = Path(folder)
|
||||
if folder.is_symlink() or not folder.is_dir() or folder != folder.resolve():
|
||||
refuse('Packed candidate must be a canonical real directory')
|
||||
if {p.name for p in folder.iterdir()} != PACKED_FILES:
|
||||
refuse('Packed addon requires exactly addon.gproj, data.pak and resourceDatabase.rdb')
|
||||
result = {}
|
||||
for name in sorted(PACKED_FILES):
|
||||
path = folder/name
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size <= 0:
|
||||
refuse('Packed input must be a nonempty regular file: '+name)
|
||||
digest = hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for chunk in iter(lambda: stream.read(1024*1024), b''):
|
||||
digest.update(chunk)
|
||||
result[name] = {'bytes': path.stat().st_size, 'sha256': digest.hexdigest()}
|
||||
name, guid, dependencies = PACKED_PROJECTS[project]
|
||||
text = (folder/'addon.gproj').read_text()
|
||||
match = re.fullmatch(r'\s*GameProject\s*\{\s*ID\s+"'+name+r'"\s+GUID\s+"'+guid+r'"\s+TITLE\s+"[^"\r\n]+"\s+Dependencies\s*\{((?:\s*"[0-9A-F]{16}"\s*)+)\}\s*\}\s*', text)
|
||||
actual = re.findall(r'"([0-9A-F]{16})"', match[1]) if match else []
|
||||
expected = {PACKED_BASE, *(PACKED_PROJECTS[key][1] for key in dependencies)}
|
||||
if not match or set(actual) != expected or len(actual) != len(expected):
|
||||
refuse('Packed project identity or exact declared dependencies do not match '+project)
|
||||
return result
|
||||
|
||||
|
||||
def packed_payload_inventory(folder, project='core'):
|
||||
"""Core keeps its v1 three-file map; Chat binds all fifteen closure artifacts."""
|
||||
if project == 'core':
|
||||
return packed_inventory(folder)
|
||||
if project != 'chat':
|
||||
refuse('Packed runtime supports only Core or the complete Chat closure')
|
||||
folder = Path(folder)
|
||||
if folder.is_symlink() or not folder.is_dir() or folder != folder.resolve():
|
||||
refuse('Packed closure must be a canonical real directory')
|
||||
if {p.name for p in folder.iterdir()} != set(PACKED_PROJECTS):
|
||||
refuse('Packed Chat requires exactly core, ai, intel, map and chat directories')
|
||||
return {project+'/'+name: record for project in PACKED_PROJECTS
|
||||
for name, record in packed_inventory(folder/project, project).items()}
|
||||
|
||||
|
||||
def packed_directories(packed):
|
||||
if packed.get('project', 'core') == 'core':
|
||||
return {'core': packed['input']}
|
||||
if packed.get('project') != 'chat':
|
||||
refuse('Unknown packed runtime project')
|
||||
return {project: packed['input']/project for project in PACKED_PROJECTS}
|
||||
|
||||
|
||||
def packed_request(a):
|
||||
candidate, session = getattr(a, 'packed_core', None), getattr(a, 'packed_session', None)
|
||||
addons = getattr(a, 'packed_addons', None)
|
||||
if candidate is None and addons is None and session is None:
|
||||
return None
|
||||
if candidate is not None and addons is not None:
|
||||
refuse('--packed-core and --packed-addons are mutually exclusive')
|
||||
project = 'chat' if addons is not None else 'core'
|
||||
candidate = addons if addons is not None else candidate
|
||||
if candidate is None or session is None or a.project != project:
|
||||
refuse('Packed input and --packed-session require their matching --project '+project)
|
||||
if a.profile != Path('/tmp/erri-game-profile'):
|
||||
refuse('Packed mode owns --packed-session/profile; do not supply --profile')
|
||||
if a.settle != 15:
|
||||
refuse('Packed mode requires the canonical 15-second readiness settling window')
|
||||
session = Path(session)
|
||||
if (not session.is_absolute() or session.parent != Path('/tmp') or session != session.resolve()
|
||||
or not re.fullmatch('erri-packed-'+project+r'-[A-Za-z0-9_-]+', session.name)
|
||||
or session.exists() or session.is_symlink()):
|
||||
refuse('Packed session must be a new canonical /tmp/erri-packed-'+project+'-<name> directory')
|
||||
candidate = Path(candidate)
|
||||
manifest = packed_payload_inventory(candidate, project)
|
||||
target = session/'input/addons' if project == 'chat' else session/'input/addons/core'
|
||||
return {'candidate': candidate, 'session': session, 'input': target, 'manifest': manifest, 'project': project}
|
||||
|
||||
|
||||
def prepare_packed(packed):
|
||||
"""Called only while holding the ordinary runner's existing prefix lock."""
|
||||
packed['session'].mkdir(exist_ok=False)
|
||||
target = packed['input']
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
project = packed.get('project', 'core')
|
||||
if packed_payload_inventory(packed['candidate'], project) != packed['manifest']:
|
||||
refuse('Packed candidate changed before private copy')
|
||||
for name in sorted(packed['manifest']):
|
||||
# Exclusive creation; never hardlink the candidate or redirect the copy.
|
||||
(target/name).parent.mkdir(parents=True, exist_ok=True)
|
||||
with (packed['candidate']/name).open('rb') as source, (target/name).open('xb') as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
if packed_payload_inventory(target, project) != packed['manifest'] or packed_payload_inventory(packed['candidate'], project) != packed['manifest']:
|
||||
refuse('Private packed copy or candidate changed during copy')
|
||||
|
||||
|
||||
def packed_snapshot(packed, command, profile):
|
||||
project = packed.get('project', 'core')
|
||||
manifest = packed_payload_inventory(packed['input'], project)
|
||||
if manifest != packed['manifest'] or packed_payload_inventory(packed['candidate'], project) != manifest:
|
||||
refuse('Packed input changed before launch')
|
||||
value = {'schema_version': 1, 'kind': 'packed-core-runtime-input', 'launch_id': str(uuid.uuid4()),
|
||||
'captured_unix': time.time(), 'candidate_directory': str(packed['candidate']),
|
||||
'runtime_directory': str(packed['input']), 'profile': str(profile),
|
||||
'command': list(command), 'artifacts': manifest,
|
||||
'build_qualification': 'unqualified candidate; packaging verdict is unchanged'}
|
||||
if project == 'chat':
|
||||
value.update(schema_version=2, kind='packed-chat-runtime-input', project='chat', addon_order=list(PACKED_PROJECTS))
|
||||
raw = (json.dumps(value, indent=2)+'\n').encode()
|
||||
(packed['session']/PACKED_MANIFEST).write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def packed_seal(packed, raw, session):
|
||||
"""Fail closed on changed/deleted inputs, retaining a useful failure record."""
|
||||
metadata = json.loads(raw)
|
||||
unchanged, error = False, None
|
||||
try:
|
||||
project = packed.get('project', 'core')
|
||||
unchanged = (packed_payload_inventory(packed['input'], project) == metadata['artifacts']
|
||||
and packed_payload_inventory(packed['candidate'], project) == metadata['artifacts'])
|
||||
except (OSError, ValueError) as caught:
|
||||
error = str(caught)
|
||||
console = session/'console.log' if session else None
|
||||
return {'packed_only': True, 'packed_input_provenance': PACKED_MANIFEST,
|
||||
'packed_input_provenance_sha256': hashlib.sha256(raw).hexdigest(),
|
||||
'packed_launch_id': metadata['launch_id'], 'stopped_unix': time.time(),
|
||||
'packed_inputs_unchanged_after_run': unchanged, 'packed_input_error': error,
|
||||
'console_sha256': hashlib.sha256(console.read_bytes()).hexdigest() if console and console.is_file() else None,
|
||||
'build_qualified': False, 'release_qualified': False}
|
||||
|
||||
|
||||
def native_path(value, game_root):
|
||||
if value.startswith('$'):
|
||||
return value # Unknown engine aliases must not normalize into allowed paths.
|
||||
value = value.replace('\\', '/')
|
||||
if value[:3].lower() == 'z:/':
|
||||
value = value[2:]
|
||||
if not value.startswith('/'):
|
||||
value = str(game_root/value)
|
||||
return os.path.normpath(value)
|
||||
|
||||
|
||||
def packed_evidence(content, packed, steam, profile):
|
||||
project = packed.get('project', 'core')
|
||||
report = game_evidence(content, project)
|
||||
directories = packed_directories(packed)
|
||||
game_root = steam/'steamapps/common/Arma Reforger'
|
||||
expected = {'5614BBCCBB55ED1C': str(game_root/'addons/core/core.gproj'),
|
||||
'58D0FB3206B6F859': str(game_root/'addons/data/ArmaReforger.gproj')}
|
||||
expected.update({PACKED_PROJECTS[key][1]: str(folder/'addon.gproj') for key, folder in directories.items()})
|
||||
blocks = re.findall(r"Loaded addons:\r?\n((?:[^\n]*ENGINE[^\n]*gproj:[^\n]*\n)+)", content)
|
||||
complete = False
|
||||
violations = []
|
||||
for block in blocks:
|
||||
entries = re.findall(r"gproj: '([^']+)' guid: '([0-9A-Fa-f]{16})'", block)
|
||||
if len(entries) != len(block.splitlines()):
|
||||
violations.append('Malformed loaded addon entry')
|
||||
seen = set()
|
||||
for path, guid in entries:
|
||||
guid = guid.upper()
|
||||
if guid in seen or guid not in expected or native_path(path, game_root) != expected.get(guid):
|
||||
violations.append('Unexpected, duplicate or redirected loaded addon: '+path+' '+guid)
|
||||
seen.add(guid)
|
||||
complete = seen == set(expected)
|
||||
mounts = re.findall(r"FileSystem: Adding package '([^']+)' \(pak count: (\d+)\) to filesystem under name ([^\s]+)", content)
|
||||
expected_mounts = {'core': str(game_root/'addons/core'), 'ArmaReforger': str(game_root/'addons/data')}
|
||||
expected_databases = {PACKED_PROJECTS[key][0]: str(folder/'resourceDatabase.rdb') for key, folder in directories.items()}
|
||||
expected_mounts.update({PACKED_PROJECTS[key][0]: str(folder) for key, folder in directories.items()})
|
||||
mounted = set()
|
||||
for path, count, name in mounts:
|
||||
if name not in expected_mounts or native_path(path, game_root) != expected_mounts.get(name) or int(count) < 1 or (name in expected_databases and int(count) != 1):
|
||||
violations.append('Unexpected packed mount: '+path+' '+name)
|
||||
else:
|
||||
mounted.add(name)
|
||||
for path, name in re.findall(r"FileSystem: Adding relative directory '([^']+)' to filesystem under name ([^\s]+)", content):
|
||||
if name.startswith('ERR_'):
|
||||
violations.append('Loose successor directory mount: '+path)
|
||||
addon_root = packed['input'] if project == 'chat' else packed['input'].parent
|
||||
allowed_dirs = {str(addon_root), str(game_root/'addons'), str(profile/'addons')}
|
||||
dirs = re.findall(r"ENGINE\s*:\s*dir: '([^']+)'", content)
|
||||
for path in dirs:
|
||||
# Native $profile: maps to <CLI profile>/profile, so this exact observed
|
||||
# engine alias denotes <CLI profile>/addons. No general alias/traversal
|
||||
# expansion is accepted here or in loaded project/package paths.
|
||||
resolved = str(profile/'addons') if path == '$profile:../addons/' else native_path(path, game_root)
|
||||
if resolved not in allowed_dirs:
|
||||
violations.append('Unexpected addon search directory: '+path)
|
||||
database_paths = re.findall(r'ResourceDB: loading cache \(id=\d+ name=(ERR_[^\s]+) path=([^\r\n]+)\)', content)
|
||||
databases = set()
|
||||
for name, path in database_paths:
|
||||
if name not in expected_databases or native_path(path, game_root) != expected_databases.get(name):
|
||||
violations.append('Unexpected or redirected successor resource database: '+name+' '+path)
|
||||
else:
|
||||
databases.add(name)
|
||||
database_ok = databases == set(expected_databases)
|
||||
modules = re.findall(r'Module: (GameLib|Game);[^\n]*defines:\s*"([^"]*)"', content)
|
||||
normal_modules = {name for name, flags in modules} == {'GameLib', 'Game'} and all(not {'WORKBENCH', 'ENF_WB'}.intersection(flags.split(',')) for _, flags in modules)
|
||||
if any({'WORKBENCH', 'ENF_WB'}.intersection(flags.split(',')) for _, flags in modules):
|
||||
violations.append('Workbench module defines in ordinary packed run')
|
||||
path_proof = complete and mounted == set(expected_mounts) and database_ok and bool(dirs)
|
||||
failed = report['failed'] or bool(violations)
|
||||
return {**report, 'packed_only': True, 'packed_loaded_paths_verified': path_proof,
|
||||
'packed_input_violations': violations, 'packed_normal_modules': normal_modules,
|
||||
'failed': failed, 'ready': report['ready'] and path_proof and normal_modules and not failed,
|
||||
'build_qualified': False, 'release_qualified': False,
|
||||
'scope': 'packed-only '+project+' ordinary local startup; explicit noSplash; no packaging, visible UI, escape, multiplayer or release qualification'}
|
||||
|
||||
def assert_no_game(proc_root=Path('/proc')):
|
||||
"""Read-only and fail closed; also recognizes Wine's truncated comm name."""
|
||||
for proc in proc_root.iterdir():
|
||||
@@ -123,14 +345,19 @@ def validate_paths(root, steam, compat, profile):
|
||||
refuse(f'Unexpected profile symlink: {path}')
|
||||
|
||||
|
||||
def command_for(root, steam, compat, profile, project):
|
||||
def command_for(root, steam, compat, profile, project, packed=None):
|
||||
if project not in {'core', 'chat', CONTROL}:
|
||||
refuse('Unsupported ordinary game project/control')
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
|
||||
'-w', '1280', '-h', '720', '-r', '30', '--',
|
||||
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
|
||||
str(steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe')]
|
||||
if project != CONTROL:
|
||||
if packed is not None:
|
||||
if project != packed.get('project', 'core') or project not in {'core', 'chat'}:
|
||||
refuse('Packed input project does not match requested ordinary scenario')
|
||||
addon_root = packed['input'] if project == 'chat' else packed['input'].parent
|
||||
command += ['-addonsDir', 'Z:'+str(addon_root), '-addons', PACKED_PROJECTS[project][1]]
|
||||
elif project != CONTROL:
|
||||
project_text = (root/'addons'/project/'addon.gproj').read_text()
|
||||
guid = re.search(r'\bGUID "([A-F0-9]{16})"', project_text)[1]
|
||||
command += ['-addonsDir', 'Z:'+str(root/'addons'), '-addons', guid]
|
||||
@@ -264,7 +491,9 @@ class StartupMonitor:
|
||||
|
||||
|
||||
def run(a):
|
||||
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
|
||||
packed = packed_request(a)
|
||||
steam, compat = a.steam.resolve(), a.compat.resolve()
|
||||
profile = packed['session']/'profile' if packed else a.profile.resolve()
|
||||
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
|
||||
refuse('Use timeout 60..1800 seconds and settle 5..120 seconds')
|
||||
if not 0 <= a.failure_grace <= 30:
|
||||
@@ -284,11 +513,11 @@ def run(a):
|
||||
except BlockingIOError:
|
||||
refuse('Another test runner owns this disposable prefix')
|
||||
assert_prefix_idle(compat)
|
||||
return run_locked(a, steam, compat, profile)
|
||||
return run_locked(a, steam, compat, profile, packed)
|
||||
|
||||
|
||||
def run_locked(a, steam, compat, profile):
|
||||
command = command_for(ROOT, steam, compat, profile, a.project)
|
||||
def run_locked(a, steam, compat, profile, packed=None):
|
||||
command = command_for(ROOT, steam, compat, profile, a.project, packed)
|
||||
separator = command.index("--")
|
||||
proton, executable = Path(command[separator+1]), Path(command[separator+3])
|
||||
if not proton.is_file() or not executable.is_file():
|
||||
@@ -297,6 +526,8 @@ def run_locked(a, steam, compat, profile):
|
||||
refuse('gamescope and pactl are required')
|
||||
# Refuse launcher/profile reuse; repeat immediately before any launch setup.
|
||||
assert_no_game()
|
||||
if packed:
|
||||
prepare_packed(packed)
|
||||
profile.mkdir(parents=True, exist_ok=True)
|
||||
marker = profile/PROFILE_MARKER
|
||||
if not marker.exists():
|
||||
@@ -321,7 +552,8 @@ def run_locked(a, steam, compat, profile):
|
||||
sink_module = None
|
||||
child = None
|
||||
session = None
|
||||
captured = snapshot(ROOT, command)
|
||||
captured = None if packed else snapshot(ROOT, command)
|
||||
packed_raw = packed_snapshot(packed, command, profile) if packed else None
|
||||
started = time.time()
|
||||
monitor = StartupMonitor(time.monotonic(), a.timeout, a.settle, a.failure_grace)
|
||||
result = 'timeout'
|
||||
@@ -356,10 +588,13 @@ def run_locked(a, steam, compat, profile):
|
||||
if candidates:
|
||||
if session is None:
|
||||
session = candidates[0]
|
||||
persist(session, captured)
|
||||
if packed:
|
||||
(session/PACKED_MANIFEST).write_bytes(packed_raw)
|
||||
else:
|
||||
persist(session, captured)
|
||||
console = session/'console.log'
|
||||
content = console.read_text(errors='replace') if console.is_file() else ''
|
||||
evidence = game_evidence(content, a.project)
|
||||
evidence = packed_evidence(content, packed, steam, profile) if packed else game_evidence(content, a.project)
|
||||
outcome = monitor.observe(evidence, time.monotonic(), child.poll() is not None)
|
||||
if outcome is not None:
|
||||
result = outcome; break
|
||||
@@ -389,16 +624,28 @@ def run_locked(a, steam, compat, profile):
|
||||
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass # Never replace the user's audio defaults to compensate.
|
||||
sealed = seal_run(ROOT, session, captured) if cleanup_ok and session and (session/'console.log').is_file() else {}
|
||||
final = game_evidence((session/'console.log').read_text(errors='replace'), a.project) if session and (session/'console.log').is_file() else {}
|
||||
if packed:
|
||||
sealed = packed_seal(packed, packed_raw, session)
|
||||
else:
|
||||
sealed = seal_run(ROOT, session, captured) if cleanup_ok and session and (session/'console.log').is_file() else {}
|
||||
content = (session/'console.log').read_text(errors='replace') if session and (session/'console.log').is_file() else ''
|
||||
if packed:
|
||||
final = packed_evidence(content, packed, steam, profile)
|
||||
else:
|
||||
final = game_evidence(content, a.project) if session and (session/'console.log').is_file() else {}
|
||||
final = monitor.annotate(final)
|
||||
if final.get('failed'):
|
||||
result = 'game-failed'
|
||||
if sealed and not sealed['source_unchanged_after_run']:
|
||||
if packed and not sealed['packed_inputs_unchanged_after_run']:
|
||||
result = 'packed-input-changed'
|
||||
if not packed and sealed and not sealed['source_unchanged_after_run']:
|
||||
result = 'source-changed'
|
||||
if a.project == CONTROL and result == 'game-started':
|
||||
result = 'vanilla-control-started'
|
||||
summary = {**sealed, **bindings(captured), 'started_unix': started, 'result': result,
|
||||
if packed and result == 'game-started':
|
||||
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
|
||||
source_bindings = {} if packed else bindings(captured)
|
||||
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
|
||||
'session': str(session) if session else None, 'project': a.project,
|
||||
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
|
||||
'failure_grace_seconds': a.failure_grace,
|
||||
@@ -407,14 +654,19 @@ def run_locked(a, steam, compat, profile):
|
||||
'executable': str(executable), 'dedicated_server': False,
|
||||
'display': 'headless Gamescope', 'audio': '-noSound plus own silent sink',
|
||||
'control_only': a.project == CONTROL,
|
||||
'source_manifest_scope': 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
|
||||
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
|
||||
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
|
||||
if packed:
|
||||
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
|
||||
final['packed_consumption_verified'] = summary['packed_consumption_verified']
|
||||
(packed['session']/'packed-runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
||||
(packed['session']/'packed-startup-report.json').write_text(json.dumps(final, indent=2)+'\n')
|
||||
if session:
|
||||
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
||||
(session/'game-startup-report.json').write_text(json.dumps(final, indent=2)+'\n')
|
||||
shutil.copyfile(launch_log, session/'headless-launch.log')
|
||||
print(json.dumps(summary, indent=2), flush=True)
|
||||
return 0 if result in {'game-started', 'vanilla-control-started'} else 1
|
||||
return 0 if result in {'game-started', 'vanilla-control-started', 'packed-core-started', 'packed-chat-started'} else 1
|
||||
|
||||
|
||||
def main():
|
||||
@@ -423,6 +675,10 @@ def main():
|
||||
parser.add_argument('--compat', type=Path, required=True)
|
||||
parser.add_argument('--profile', type=Path, default=Path('/tmp/erri-game-profile'))
|
||||
parser.add_argument('--project', choices=['core', 'chat', CONTROL], default='core')
|
||||
packed_options = parser.add_mutually_exclusive_group()
|
||||
packed_options.add_argument('--packed-core', type=Path, help='Unqualified Core candidate directory containing exactly addon.gproj, data.pak and resourceDatabase.rdb')
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
|
||||
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
|
||||
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--settle', type=int, default=15)
|
||||
|
||||
@@ -11,8 +11,10 @@ p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
||||
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
||||
p.add_argument('--smoke', choices=['mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--smoke', choices=['mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
|
||||
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
|
||||
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
|
||||
p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations')
|
||||
p.add_argument('--rounds', type=int, default=2)
|
||||
p.add_argument('--timeout', type=int, default=360)
|
||||
@@ -23,8 +25,10 @@ if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800:
|
||||
p.error('Use 1..10 rounds and a 60..1800 second timeout')
|
||||
if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 500):
|
||||
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
|
||||
if a.smoke in {'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
||||
p.error('mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
||||
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
||||
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
||||
if a.flight_autohover and (a.project != 'core' or a.smoke != 'powered-flight' or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
|
||||
if a.flight_action_inputs and (a.project != 'core' or a.smoke != 'powered-flight'): p.error('flight-action-inputs requires the Core powered-flight fixture')
|
||||
if a.smoke == 'powered-flight' and (a.project != 'core' or a.rounds != 1 or a.timeout < 420): p.error('powered-flight requires Core, one round, and at least 420 seconds')
|
||||
if a.smoke == 'station-teardown' and (a.project != 'core' or a.rounds != 1): p.error('station-teardown requires --project core --rounds 1')
|
||||
if a.smoke == 'environment-restart':
|
||||
@@ -63,6 +67,8 @@ for proc in Path('/proc').iterdir():
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons'/a.project/'addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
|
||||
if a.station_teardown_diagnostics: command += ['-errStationTeardownDiag', '1']
|
||||
if a.flight_input_diagnostics: command += ['-errFlightInputDiag', '1']
|
||||
if a.flight_action_inputs: command += ['-errFlightActionInputs', '1']
|
||||
if a.flight_autohover: command += ['-errFlightAutohover', '1']
|
||||
if a.project == 'core':
|
||||
if a.smoke not in {'station-teardown', 'prison-breach', 'powered-flight', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
|
||||
command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin'
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build unqualified native PAK candidates in a fresh private /tmp session.
|
||||
Never publish. Native package contents and consumption remain separate checks.
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from source_bundle import build, inventory, PROJECTS
|
||||
from headless_game import assert_no_game, assert_prefix_idle, cleanup
|
||||
from native_report import analyze
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ORDER = tuple(PROJECTS)
|
||||
# Record the direct Proton/Wine status independently of Gamescope's exit status.
|
||||
# This helper lives only in the owned session and never interprets shell text.
|
||||
CHILD_RECORDER = """import json, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
command = sys.argv[3:]
|
||||
environment = os.environ.copy()
|
||||
environment.update(json.loads(Path(sys.argv[2]).read_text()))
|
||||
code = subprocess.call(command, env=environment)
|
||||
with Path(sys.argv[1]).open('x') as output:
|
||||
json.dump({'command': command, 'exit_code': code}, output)
|
||||
sys.exit(code)
|
||||
"""
|
||||
|
||||
|
||||
def digest(path):
|
||||
h = hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b''):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def save(path, value):
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
|
||||
|
||||
|
||||
def project_order(project):
|
||||
if project not in PROJECTS:
|
||||
raise ValueError('Unknown successor project')
|
||||
needed = {project, *PROJECTS[project][2]}
|
||||
return [name for name in ORDER if name in needed]
|
||||
|
||||
|
||||
def validate_session(root, steam, compat, session):
|
||||
if session.exists() or session.is_symlink():
|
||||
raise ValueError('--session must not already exist; outputs are never reused')
|
||||
if session != session.resolve() or session.parent != Path('/tmp') or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}', session.name):
|
||||
raise ValueError('--session must be a fresh direct child of /tmp')
|
||||
if any(session.is_relative_to(p) or p.is_relative_to(session) for p in (root, steam, compat)):
|
||||
raise ValueError('Session must be separate from source, Steam and prefix')
|
||||
if (not compat.is_relative_to(Path('/tmp')) or compat.is_relative_to(root)
|
||||
or compat.is_relative_to(steam) or (compat/'pfx').is_symlink()
|
||||
or (compat/'pfx/system.reg').is_symlink() or not (compat/'pfx/system.reg').is_file()):
|
||||
raise ValueError('--compat must be a disposable cloned prefix under /tmp, outside source and Steam, with a real pfx')
|
||||
if (compat/'.erri-workbench.lock').is_symlink():
|
||||
raise ValueError('Disposable prefix lock must not be a symlink')
|
||||
|
||||
|
||||
def copy_payload(root, session):
|
||||
# Same reviewed authored schema, resource identities and dependency checks as
|
||||
# source_bundle; no caches, original content, logs or vanilla bytes copied.
|
||||
archive, manifest = build(root)
|
||||
authored = {name: sha for name, sha in manifest['files'].items() if name.startswith('addons/')}
|
||||
session.mkdir(mode=0o700) # atomic exclusive ownership; never exist_ok
|
||||
source = session/'source'
|
||||
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
|
||||
for name, sha in authored.items():
|
||||
data = bundle.read(name)
|
||||
if hashlib.sha256(data).hexdigest() != sha:
|
||||
raise ValueError('Validated bundle hash mismatch')
|
||||
destination = source/name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open('xb') as output:
|
||||
output.write(data)
|
||||
save(session/'authored-before.json', authored)
|
||||
return authored
|
||||
|
||||
|
||||
def copied_authored_snapshot(source, authored):
|
||||
result = {}
|
||||
for name in authored:
|
||||
path = source/name
|
||||
result[name] = digest(path) if path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(source.resolve()) else None
|
||||
# The shared inventory rejects unreviewed additions and ignores only its
|
||||
# explicit generated-cache schema. Added authored scripts/resources matter.
|
||||
discovered = inventory(source)
|
||||
for name, sha in discovered.items():
|
||||
if name not in result:
|
||||
result[name] = sha
|
||||
return result
|
||||
|
||||
|
||||
def copied_authored_unchanged(source, authored):
|
||||
return copied_authored_snapshot(source, authored) == authored
|
||||
|
||||
|
||||
def command_for(steam, session, project, silent_workbench=False):
|
||||
if project not in PROJECTS:
|
||||
raise ValueError('Unknown successor project')
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
|
||||
'-w', '1280', '-h', '720', '-r', '30', '--',
|
||||
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
|
||||
str(steam/'steamapps/common/Arma Reforger Tools/Workbench/ArmaReforgerWorkbenchSteamDiag.exe'),
|
||||
'-gproj', 'Z:'+str(session/'source/addons'/project/'addon.gproj'),
|
||||
'-addonsDir', 'Z:'+str(session/'source/addons'),
|
||||
'-profile', 'Z:'+str(session/'profiles'/project),
|
||||
'-logconsole', '-noBackend', '-noSound', '-disableCrashReporter',
|
||||
'-wbModule=ResourceManager', '-packAddon',
|
||||
'-packAddonDir', 'Z:'+str(session/'packages'/project),
|
||||
'-packAddonCacheDir', 'Z:'+str(session/'cache'/project)]
|
||||
if silent_workbench:
|
||||
command.append('-wbSilent')
|
||||
return command
|
||||
|
||||
|
||||
|
||||
def child_status(path, expected):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(record, dict) or record.get('command') != expected or type(record.get('exit_code')) is not int:
|
||||
return None
|
||||
return record['exit_code']
|
||||
|
||||
|
||||
def log_evidence(paths):
|
||||
records, failures = {}, []
|
||||
for path in sorted(paths):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError('Unexpected nonregular native log')
|
||||
content = path.read_text(errors='replace')
|
||||
report = analyze(content, path)
|
||||
errors = report['engine_errors'] + report['script_or_runtime_failures'] + report['missing_end_screen']
|
||||
errors += [line.strip() for line in content.splitlines()
|
||||
if re.search(r"can't compile|packing (?:has )?failed|packAddon.*failed|fatal error", line, re.I)]
|
||||
if path.name in {'error.log', 'crash.log'} and content.strip():
|
||||
errors.append('Nonempty native '+path.name)
|
||||
records[str(path)] = {'sha256': digest(path), 'bytes': path.stat().st_size, 'errors': errors}
|
||||
failures.extend(errors)
|
||||
return records, failures
|
||||
|
||||
|
||||
def package_inventory(folder):
|
||||
records = {}
|
||||
if folder.is_symlink():
|
||||
raise ValueError('Native package root is a symlink')
|
||||
if not folder.is_dir():
|
||||
return records
|
||||
for path in folder.rglob('*'):
|
||||
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
||||
raise ValueError('Nonregular native package output')
|
||||
if path.is_file():
|
||||
records[path.relative_to(folder).as_posix()] = {'bytes': path.stat().st_size, 'sha256': digest(path)}
|
||||
return records
|
||||
|
||||
|
||||
def candidate_ok(exit_code, result, cleanup_ok, unchanged, logs, errors, packages):
|
||||
return (exit_code == 0 and result in {'process-exited', 'native-child-exited'} and cleanup_ok and unchanged
|
||||
and bool(logs) and not errors
|
||||
and any(name.lower().endswith('.pak') and info['bytes'] > 0 for name, info in packages.items())
|
||||
and all(info['bytes'] > 0 for name, info in packages.items() if name.lower().endswith('.pak')))
|
||||
|
||||
|
||||
def quiet_streams(application_id, sink, compat):
|
||||
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True, timeout=5))
|
||||
for stream in streams:
|
||||
props = stream.get('properties', {})
|
||||
owned = props.get('application.id') == application_id
|
||||
pid = str(props.get('application.process.id', ''))
|
||||
if not owned and pid.isdigit():
|
||||
try:
|
||||
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||
pass
|
||||
if owned:
|
||||
for args in [['move-sink-input', str(stream['index']), sink], ['set-sink-input-mute', str(stream['index']), '1']]:
|
||||
subprocess.run(['pactl', *args], check=True, timeout=5, stdout=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False):
|
||||
command = command_for(steam, session, project, silent_workbench)
|
||||
proton = Path(command[command.index('--')+1])
|
||||
executable = Path(command[command.index('--')+3])
|
||||
profile = session/'profiles'/project
|
||||
(profile/'addons').mkdir(parents=True)
|
||||
(profile/'addons/ArmaReforger').symlink_to(steam/'steamapps/common/Arma Reforger/addons/data', target_is_directory=True)
|
||||
for name in ('packages', 'cache', 'evidence'):
|
||||
(session/name/project).mkdir(parents=True)
|
||||
evidence = session/'evidence'/project
|
||||
separator = command.index('--')
|
||||
native_command = command[separator+1:]
|
||||
recorder = evidence/'native-child.py'
|
||||
recorder.write_text(CHILD_RECORDER)
|
||||
status_path = evidence/'native-exit.json'
|
||||
environment_path = evidence/'native-prefix-environment.json'
|
||||
prefix_environment = {'STEAM_COMPAT_DATA_PATH': str(compat), 'STEAM_COMPAT_CLIENT_INSTALL_PATH': str(steam),
|
||||
'WINEPREFIX': str(compat/'pfx')}
|
||||
save(environment_path, prefix_environment)
|
||||
command = command[:separator+1] + [sys.executable, str(recorder), str(status_path), str(environment_path), *native_command]
|
||||
save(evidence/'launch.json', {'command': command, 'native_command': native_command, 'authored': authored, 'started_unix': time.time(),
|
||||
'executable_sha256': digest(executable), 'proton_sha256': digest(proton),
|
||||
'child_recorder_sha256': digest(recorder), 'native_environment_sha256': digest(environment_path),
|
||||
'silent_workbench_experiment': silent_workbench})
|
||||
run_id = uuid.uuid4().hex
|
||||
sink, application_id = 'erri_pack_'+run_id, 'org.escapistsrereintroduced.pack.'+run_id
|
||||
env = os.environ.copy()
|
||||
env.update(prefix_environment)
|
||||
env.update(PULSE_SINK=sink, PULSE_PROP='application.id='+application_id)
|
||||
# Prefix-idle checks must not mistake a lingering display wrapper for Wine.
|
||||
# Only the recorder's direct Proton child receives these three overrides.
|
||||
display_env = {key: value for key, value in env.items() if key not in prefix_environment}
|
||||
child = None
|
||||
module = None
|
||||
exit_code = None
|
||||
result, cleanup_ok, runner_error = 'timeout', True, None
|
||||
launch_log = evidence/'launch.log'
|
||||
deadline = time.monotonic()+timeout
|
||||
launcher_exited_at = None
|
||||
try:
|
||||
assert_no_game()
|
||||
assert_prefix_idle(compat)
|
||||
module = subprocess.check_output(['pactl', 'load-module', 'module-null-sink', 'sink_name='+sink], text=True, timeout=5).strip()
|
||||
for args in [['set-sink-mute', sink, '1'], ['set-sink-volume', sink, '0%']]:
|
||||
subprocess.run(['pactl', *args], check=True, timeout=5)
|
||||
assert_prefix_idle(compat)
|
||||
with launch_log.open('x') as output:
|
||||
child = subprocess.Popen(command, cwd=executable.parent, env=display_env, stdout=output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
while time.monotonic() < deadline:
|
||||
quiet_streams(application_id, sink, compat)
|
||||
if time.monotonic() >= deadline:
|
||||
break # audio/process observation must not extend the deadline
|
||||
exit_code = child.poll()
|
||||
recorded_native_exit = child_status(status_path, native_command)
|
||||
if exit_code is not None or recorded_native_exit is not None:
|
||||
# Gamescope exit alone cannot prove Workbench ended.
|
||||
# Never qualify output completed by forcibly killing a child.
|
||||
if launcher_exited_at is None:
|
||||
launcher_exited_at = time.monotonic()
|
||||
try:
|
||||
assert_prefix_idle(compat)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
result = 'process-exited' if exit_code is not None else 'native-child-exited'
|
||||
break
|
||||
except ValueError:
|
||||
if time.monotonic()-launcher_exited_at >= 10:
|
||||
result = 'native-or-launcher-exited-prefix-active'
|
||||
break
|
||||
time.sleep(min(.5, max(0, deadline-time.monotonic())))
|
||||
except KeyboardInterrupt:
|
||||
result = 'interrupted'
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
result, runner_error = 'runner-failed', str(error)
|
||||
finally:
|
||||
if child is not None:
|
||||
try:
|
||||
# Reuse guarded ordinary runner cleanup, only after owning the
|
||||
# shared prefix lock and proving that prefix was initially idle.
|
||||
cleanup(child, proton, env)
|
||||
assert_prefix_idle(compat)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
cleanup_ok, runner_error = False, str(error)
|
||||
if module is not None:
|
||||
try:
|
||||
subprocess.run(['pactl', 'unload-module', module], check=True, timeout=5, stdout=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
cleanup_ok, runner_error = False, str(error)
|
||||
paths = list((profile/'logs').rglob('*.log'))
|
||||
if launch_log.is_file():
|
||||
paths.append(launch_log)
|
||||
try:
|
||||
if any(not path.resolve().is_relative_to(session.resolve()) for path in paths):
|
||||
raise ValueError('Native log escaped owned session')
|
||||
logs, errors = log_evidence(paths)
|
||||
packages = package_inventory(session/'packages'/project)
|
||||
after = copied_authored_snapshot(session/'source', authored)
|
||||
save(evidence/'authored-after.json', after)
|
||||
unchanged = after == authored
|
||||
except (OSError, ValueError) as error:
|
||||
logs, packages, unchanged = {}, {}, False
|
||||
errors = ['Output/evidence validation failed: '+str(error)]
|
||||
native_logs = [name for name in logs if Path(name).name == 'console.log' and logs[name]['bytes'] > 0]
|
||||
native_exit = child_status(status_path, native_command)
|
||||
wrapper_ok = exit_code == 0 or (exit_code is None and result == 'native-child-exited')
|
||||
candidate = wrapper_ok and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
|
||||
report = {'project': project, 'result': 'native-pack-candidate' if candidate else ('validation-failed' if result in {'process-exited', 'native-child-exited'} else result),
|
||||
'candidate_created': candidate, 'native_exit_code': native_exit, 'launcher_exit_code': exit_code,
|
||||
'completion_observed': result, 'display_cleanup_after_native_exit': result == 'native-child-exited',
|
||||
'silent_workbench_experiment': silent_workbench,
|
||||
'native_status_sha256': digest(status_path) if status_path.is_file() and not status_path.is_symlink() else None,
|
||||
'cleanup_confirmed': cleanup_ok,
|
||||
'copied_authored_unchanged': unchanged, 'runner_error': runner_error,
|
||||
'logs': logs, 'errors': errors, 'packages': packages,
|
||||
'package_contents_verified': False, 'native_consumption_verified': False,
|
||||
'tool_completion_semantics_verified': False, 'release_qualified': False,
|
||||
'qualification': 'PENDING: inspect native contents and load packed-only addon closure; no release claim'}
|
||||
save(evidence/'report.json', report)
|
||||
return report
|
||||
|
||||
|
||||
def run(a, root=ROOT):
|
||||
root, steam, compat = root.resolve(), a.steam.resolve(), a.compat.resolve()
|
||||
session = a.session.absolute() # refuse existing symlinks before resolve
|
||||
validate_session(root, steam, compat, session)
|
||||
if not 30 <= a.timeout <= 1800:
|
||||
raise ValueError('--timeout must be 30..1800 seconds per addon')
|
||||
command = command_for(steam, session, a.project)
|
||||
separator = command.index('--')
|
||||
for path in (Path(command[separator+1]), Path(command[separator+3]), steam/'steamapps/common/Arma Reforger/addons/data/ArmaReforger.gproj'):
|
||||
if not path.is_file():
|
||||
raise ValueError('Installed runtime dependency missing: '+str(path))
|
||||
if not shutil.which('gamescope') or not shutil.which('pactl'):
|
||||
raise ValueError('gamescope and pactl required')
|
||||
assert_no_game()
|
||||
descriptor = os.open(compat/'.erri-workbench.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, 'a') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise ValueError('Another native runner owns the disposable prefix')
|
||||
assert_prefix_idle(compat)
|
||||
authored = copy_payload(root, session)
|
||||
reports = []
|
||||
order = project_order(a.project)
|
||||
continue_on_failure = getattr(a, 'continue_on_failure', False)
|
||||
stopped_reason = None
|
||||
for project in order:
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, getattr(a, 'silent_workbench', False))
|
||||
reports.append(report)
|
||||
# Diagnostic continuation never grants authority to reuse an unsafe
|
||||
# prefix or a changed source copy. Timeout/interruption also stop.
|
||||
if not report.get('cleanup_confirmed'):
|
||||
stopped_reason = 'owned cleanup unconfirmed'
|
||||
elif report.get('runner_error'):
|
||||
stopped_reason = 'native runner error'
|
||||
elif not report.get('copied_authored_unchanged'):
|
||||
stopped_reason = 'copied authored source changed or unverifiable'
|
||||
elif report.get('completion_observed') not in {'process-exited', 'native-child-exited'}:
|
||||
stopped_reason = 'native completion not safely observed'
|
||||
elif not report['candidate_created'] and not continue_on_failure:
|
||||
stopped_reason = 'candidate failed; continuation not requested'
|
||||
if stopped_reason:
|
||||
break
|
||||
if len(reports) < len(order):
|
||||
try:
|
||||
assert_prefix_idle(compat)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
stopped_reason = 'prefix not verifiably idle before next addon: '+str(error)
|
||||
break
|
||||
try:
|
||||
original_after = inventory(root)
|
||||
save(session/'repository-after.json', original_after)
|
||||
original_unchanged = original_after == authored
|
||||
except (OSError, ValueError):
|
||||
original_unchanged = False
|
||||
summary = {'kind': 'UNQUALIFIED NATIVE PACKAGE ATTEMPT', 'session': str(session),
|
||||
'project_order': order, 'projects': reports,
|
||||
'continue_on_failure': continue_on_failure, 'stopped_reason': stopped_reason,
|
||||
'skipped_projects': order[len(reports):],
|
||||
'source_repository_unchanged': original_unchanged,
|
||||
'release_qualified': False, 'published': False,
|
||||
'qualification': 'PENDING native contents inspection and packed-only consumption'}
|
||||
summary['all_candidates_created'] = original_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
|
||||
save(session/'package-report.json', summary)
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0 if summary['all_candidates_created'] else 1
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--project', choices=PROJECTS, default='core', help='Build selected addon and its successor dependencies in order')
|
||||
parser.add_argument('--session', type=Path, required=True, help='New direct child of /tmp; never overwritten')
|
||||
parser.add_argument('--compat', type=Path, required=True, help='Idle disposable cloned Tools prefix under /tmp')
|
||||
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
parser.add_argument('--timeout', type=int, default=300, help='30..1800 seconds per addon')
|
||||
parser.add_argument('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
|
||||
parser.add_argument('--continue-on-failure', action='store_true', help='Diagnostic only: continue completed, safely cleaned addon attempts after validation failures; any failure keeps overall exit nonzero and unqualified')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return run(args)
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as error:
|
||||
print(json.dumps({'result': 'refused-or-failed', 'error': str(error), 'release_qualified': False}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/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('<I', take(4))[0]
|
||||
walk(path+'/', children, depth+1)
|
||||
elif kind == 1:
|
||||
raw = take(24)
|
||||
start, stored, size = struct.unpack_from('<III', raw)
|
||||
flags = (raw[18], raw[19])
|
||||
if raw[12:18] != b'\0'*6 or flags not in {(0, 0), (1, 6)}:
|
||||
raise PakError('Unsupported FILE descriptor flags/reserved bytes: '+path)
|
||||
if start < data_start or start > 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('<I', tree, 2)[0], 0)
|
||||
if offset != len(tree):
|
||||
raise PakError('Unparsed trailing FILE bytes')
|
||||
# Observed archives have contiguous, non-overlapping, completely indexed DATA.
|
||||
# Refuse sparse/unindexed/deduplicated variants instead of guessing semantics.
|
||||
cursor = data_start
|
||||
for entry in sorted(entries, key=lambda row: (row['offset'], row['stored_bytes'])):
|
||||
if entry['offset'] != cursor:
|
||||
raise PakError('Overlapping or unindexed DATA extent')
|
||||
cursor += entry['stored_bytes']
|
||||
if cursor != data_end:
|
||||
raise PakError('Unindexed trailing DATA bytes')
|
||||
return entries, total, count_seen
|
||||
|
||||
|
||||
def content_hash(stream, entry):
|
||||
stream.seek(entry['offset'])
|
||||
remaining = entry['stored_bytes']
|
||||
expected = entry['content_bytes']
|
||||
produced = 0
|
||||
hasher = hashlib.sha256()
|
||||
decoder = zlib.decompressobj() if entry['compression'] == 'zlib' else None
|
||||
|
||||
def accept(data):
|
||||
nonlocal produced
|
||||
produced += len(data)
|
||||
if produced > 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())
|
||||
Reference in New Issue
Block a user