202 lines
13 KiB
Python
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')}
|
|
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())
|