Files

202 lines
13 KiB
Python

#!/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', 'project_schema.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())