mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-16 21:00:29 +02:00
224 lines
11 KiB
Python
224 lines
11 KiB
Python
"""Fully link the shipped native ARM64 VR build without retail game data.
|
|
|
|
Run as a regular aarch64 Linux user with rootless Podman. The source pin,
|
|
container arguments, local patch and build script come from the installer.
|
|
Only a JSON build proof is exported; the temporary game binary is discarded.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MAX_BUILD_SECONDS = 45 * 60
|
|
MAX_EXECUTABLE_BYTES = 1024 ** 3
|
|
|
|
|
|
class NativeBuildError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def load_helper():
|
|
spec = importlib.util.spec_from_file_location("native_check_remote_helper", ROOT / "resources/remote_install.py")
|
|
helper = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = helper
|
|
spec.loader.exec_module(helper)
|
|
if (not re.fullmatch(r"[a-f0-9]{40}", helper.SOURCE_COMMIT)
|
|
or helper.SOURCE_URL != "https://github.com/startupfoundry/halo-ce-universal.git"):
|
|
raise NativeBuildError("The installer source URL or exact revision is unsupported by this check.")
|
|
return helper
|
|
|
|
|
|
def remaining(deadline, maximum=None):
|
|
seconds = deadline - time.monotonic()
|
|
if seconds <= 0:
|
|
raise NativeBuildError("The native build check exceeded its time limit.")
|
|
return min(seconds, maximum) if maximum is not None else seconds
|
|
|
|
|
|
def command(arguments, deadline, maximum=300):
|
|
return subprocess.run(arguments, check=True, text=True, stdout=subprocess.PIPE,
|
|
timeout=remaining(deadline, maximum)).stdout.strip()
|
|
|
|
|
|
def regular_file(path, parent):
|
|
"""Refuse links or files outside this check's private build workspace."""
|
|
path = Path(path)
|
|
relative = path.relative_to(parent)
|
|
if ".." in relative.parts:
|
|
raise NativeBuildError("The native output path escapes the private workspace.")
|
|
cursor = parent
|
|
if parent.is_symlink() or parent.resolve() != parent:
|
|
raise NativeBuildError("The native build workspace is not an ordinary private directory.")
|
|
for component in relative.parts:
|
|
cursor /= component
|
|
if cursor.is_symlink():
|
|
raise NativeBuildError("A native build output path contains a symbolic link.")
|
|
details = path.stat()
|
|
if (not stat.S_ISREG(details.st_mode) or details.st_nlink != 1
|
|
or details.st_uid != os.getuid() or not 64 <= details.st_size <= MAX_EXECUTABLE_BYTES):
|
|
raise NativeBuildError("The native build did not produce a private ordinary output file.")
|
|
return details
|
|
|
|
|
|
def native_elf_proof(path, parent):
|
|
details = regular_file(path, parent)
|
|
checksum = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
header = stream.read(64)
|
|
if (len(header) != 64 or header[:6] != b"\x7fELF\x02\x01"
|
|
or struct.unpack_from("<H", header, 16)[0] not in (2, 3)
|
|
or struct.unpack_from("<H", header, 18)[0] != 183):
|
|
raise NativeBuildError("The output is not a little-endian ELF64 AArch64 executable/library.")
|
|
checksum.update(header)
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
checksum.update(block)
|
|
if path.stat().st_size != details.st_size:
|
|
raise NativeBuildError("The native output changed during verification.")
|
|
return {"bytes": details.st_size, "sha256": checksum.hexdigest(),
|
|
"elfMachine": 183, "elfClass": "ELF64", "byteOrder": "little", "architecture": "aarch64"}
|
|
|
|
|
|
def stop_owned_container(helper, identifier):
|
|
"""Stop only this invocation's labeled container, never shared containers."""
|
|
name = "halo-frame-installer-" + identifier
|
|
inspected = subprocess.run(["podman", "container", "inspect", name], text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
|
|
if inspected.returncode:
|
|
# --rm may already have removed it, or the image pull never created it.
|
|
return
|
|
records = json.loads(inspected.stdout)
|
|
if not isinstance(records, list) or len(records) != 1 or not isinstance(records[0], dict):
|
|
raise NativeBuildError("Cannot verify the native check container before cleanup.")
|
|
record = records[0]
|
|
config = record.get("Config")
|
|
labels = config.get("Labels") if isinstance(config, dict) else None
|
|
container_id = record.get("Id")
|
|
if (not isinstance(labels, dict) or labels.get("org.halo-frame-installer.owner") != helper.OWNER
|
|
or labels.get("org.halo-frame-installer.run") != identifier
|
|
or not isinstance(container_id, str) or not re.fullmatch(r"[a-f0-9]{64}", container_id)):
|
|
raise NativeBuildError("Refusing to stop a container without this check's exact ownership labels.")
|
|
# The immutable inspected ID avoids a name being reassigned during cleanup.
|
|
subprocess.run(["podman", "stop", "--time", "10", container_id],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
|
|
subprocess.run(["podman", "rm", "--force", container_id],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
|
|
|
|
|
def build_container(helper, identifier, directory, deadline):
|
|
args = helper.build_container_args(identifier, directory)
|
|
process = subprocess.Popen(args, start_new_session=True)
|
|
try:
|
|
status = process.wait(timeout=remaining(deadline))
|
|
if status:
|
|
raise NativeBuildError(f"The shipped native ARM64 VR build failed (exit {status}).")
|
|
except BaseException:
|
|
try:
|
|
stop_owned_container(helper, identifier)
|
|
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
print("Owned container cleanup: " + str(error), file=sys.stderr, flush=True)
|
|
finally:
|
|
# Includes a still-running image pull before a container exists.
|
|
helper.stop_build_process(process)
|
|
raise
|
|
|
|
|
|
def write_proof(path, value):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
|
try:
|
|
with temporary.open("x", encoding="utf-8") as stream:
|
|
json.dump(value, stream, indent=2)
|
|
stream.write("\n")
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--report", type=Path, default=ROOT / "native-build-proof.json")
|
|
parser.add_argument("--timeout-seconds", type=int, default=MAX_BUILD_SECONDS)
|
|
options = parser.parse_args(argv)
|
|
if not 1 <= options.timeout_seconds <= MAX_BUILD_SECONDS:
|
|
parser.error("--timeout-seconds must be between 1 and 2700 (45 minutes)")
|
|
if sys.platform != "linux" or os.getuid() == 0:
|
|
raise NativeBuildError("Run this check as a regular Linux user with rootless Podman.")
|
|
if platform.machine().lower() not in ("aarch64", "arm64"):
|
|
raise NativeBuildError("This full native build check requires an AArch64 Linux runner.")
|
|
if any(shutil.which(tool) is None for tool in ("git", "podman")):
|
|
raise NativeBuildError("The native build check requires Git and rootless Podman.")
|
|
report = options.report.absolute()
|
|
if report.suffix.lower() != ".json" or report.exists() or report.is_symlink():
|
|
raise NativeBuildError("Choose a new JSON proof path so a stale report cannot imply build success.")
|
|
started = time.monotonic()
|
|
deadline = started + options.timeout_seconds
|
|
helper = load_helper()
|
|
info = json.loads(command(["podman", "info", "--format", "json"], deadline, 60))
|
|
if not info.get("host", {}).get("security", {}).get("rootless", False):
|
|
raise NativeBuildError("This check requires rootless Podman.")
|
|
patch = ROOT / "resources/frame-controls.patch"
|
|
script = ROOT / "resources/build-native.sh"
|
|
patch_bytes, script_bytes = patch.read_bytes(), script.read_bytes()
|
|
helper_sha = hashlib.sha256((ROOT / "resources/remote_install.py").read_bytes()).hexdigest()
|
|
identifier = uuid.uuid4().hex
|
|
print("Full native ARM64 VR link check: " + helper.SOURCE_COMMIT, flush=True)
|
|
with tempfile.TemporaryDirectory(prefix="halo-native-link-check-") as scratch:
|
|
directory = Path(scratch).resolve()
|
|
source = directory / "src"
|
|
command(["git", "init", str(source)], deadline, 30)
|
|
command(["git", "-C", str(source), "remote", "add", "origin", helper.SOURCE_URL], deadline, 30)
|
|
command(["git", "-C", str(source), "fetch", "--depth", "1", "origin", helper.SOURCE_COMMIT], deadline)
|
|
command(["git", "-C", str(source), "checkout", "--detach", "FETCH_HEAD"], deadline, 30)
|
|
if command(["git", "-C", str(source), "rev-parse", "HEAD"], deadline, 30) != helper.SOURCE_COMMIT:
|
|
raise NativeBuildError("The fetched native engine does not match the shipped source pin.")
|
|
staged_patch = directory / "frame-controls.patch"
|
|
staged_patch.write_bytes(patch_bytes)
|
|
command(["git", "-C", str(source), "apply", "--check", str(staged_patch)], deadline, 30)
|
|
command(["git", "-C", str(source), "apply", str(staged_patch)], deadline, 30)
|
|
(directory / "build-native.sh").write_bytes(script_bytes)
|
|
build_container(helper, identifier, directory, deadline)
|
|
binary = native_elf_proof(source / "build/linux_arm64/halo", directory)
|
|
library = native_elf_proof(source / "build/linux_arm64/libSDL3.so.0", directory)
|
|
proof = {"schemaVersion": 1, "result": "passed", "sourceUrl": helper.SOURCE_URL,
|
|
"sourceCommit": helper.SOURCE_COMMIT,
|
|
"buildContainerImage": helper.BUILD_CONTAINER_IMAGE,
|
|
"patchSha256": hashlib.sha256(patch_bytes).hexdigest(),
|
|
"buildScriptSha256": hashlib.sha256(script_bytes).hexdigest(),
|
|
"remoteHelperSha256": helper_sha, "target": "linux_arm64", "vrEnabled": True,
|
|
"releaseBuild": True, "fullNativeLink": True, "rootless": True,
|
|
"hostArchitecture": platform.machine(), "nativeExecutable": binary,
|
|
"sdlLibrary": library, "elapsedSeconds": round(time.monotonic() - started, 1),
|
|
"hardwareValidated": False, "retailGameDataUsed": False}
|
|
write_proof(report, proof)
|
|
print(json.dumps(proof, separators=(",", ":")), flush=True)
|
|
return 0
|
|
|
|
|
|
def interrupted(signum, frame):
|
|
raise NativeBuildError("The native build check was interrupted; stopping its owned container.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
signal.signal(signal.SIGTERM, interrupted)
|
|
try:
|
|
raise SystemExit(main())
|
|
except (NativeBuildError, OSError, ValueError, subprocess.SubprocessError, KeyboardInterrupt) as error:
|
|
print("Native build check failed: " + str(error), file=sys.stderr, flush=True)
|
|
raise SystemExit(1)
|