327 lines
12 KiB
Python
327 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Validate the offline Phase-1.0A RetroArch PS5 port evidence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from typing import Any
|
|
|
|
|
|
PHASE = "PHASE_1_0A_RETROARCH_PS5_PORT_BOOTSTRAP"
|
|
STATUS = "RETROARCH_PS5_SOFTWARE_PORT_BUILT"
|
|
BASELINE = "4c086f84ab2e7c53e6750b66b3b3e1f591ff6a06"
|
|
BRANCH = "codex/chimera-gfx-phase10a-retroarch-port-bootstrap"
|
|
RETROARCH_BRANCH = "codex/ps5-port-bootstrap"
|
|
RETROARCH_TAG = "v1.22.2"
|
|
RETROARCH_COMMIT = "69a4f0ea1e8aaf442ae4858f2e7f2b31a1776576"
|
|
RETROARCH_TREE = "33babf9eb7699b5d571a3063ea21c3e488c159fe"
|
|
RETROARCH_ARCHIVE_SHA256 = (
|
|
"245ef18c8fa8fbd9fbb5eb25cf43e17c6aace2f95c1ed99873cbd794012bb232"
|
|
)
|
|
SDK_COMMIT = "d2e2e585740362976a39fdd5ccf390f199a7bc37"
|
|
SDL_COMMIT = "0baf4ac49382b537ba449901b5b6d0d189bb1fbb"
|
|
PACBREW_COMMIT = "c2abcfcb60f569128abd0e8e70ad03a67bee5ea7"
|
|
DENYLIST_SHA256 = "e9603b0e3792781ad5b511afb22ef61e3d4fd4c5a16bf928f8609193bcd97783"
|
|
|
|
OFFLINE_AUTHORIZATION_FIELDS = (
|
|
"offline_source_acquisition_authorized",
|
|
"offline_source_modification_authorized",
|
|
"offline_target_build_authorized",
|
|
"offline_artifact_analysis_authorized",
|
|
"private_gitea_push_authorized",
|
|
)
|
|
DEVICE_AUTHORIZATION_FIELDS = (
|
|
"ps5_connection_authorized",
|
|
"device_transfer_authorized",
|
|
"device_execution_authorized",
|
|
"installation_authorized",
|
|
"lifecycle_authorized",
|
|
"autoload_authorized",
|
|
"device_write_authorized",
|
|
"automatic_retry",
|
|
)
|
|
DELIVERABLES = (
|
|
"docs/retroarch/phase-1.0a-port-plan.md",
|
|
"docs/retroarch/phase-1.0a-upstream-analysis.md",
|
|
"docs/retroarch/phase-1.0a-ps4-reference-delta.md",
|
|
"docs/retroarch/phase-1.0a-pacbrew-sdl-analysis.md",
|
|
"docs/retroarch/phase-1.0a-build-results.md",
|
|
"docs/retroarch/phase-1.0a-driver-status.md",
|
|
"docs/retroarch/phase-1.0a-next-device-smoke-test.md",
|
|
"manifests/retroarch/upstreams.json",
|
|
"manifests/retroarch/phase-1.0a-build.json",
|
|
"manifests/retroarch/phase-1.0a-artifacts.json",
|
|
"tests/test_retroarch_phase10a.py",
|
|
"tools/validate_retroarch_phase10a.py",
|
|
"packaging/retroarch/phase10a/SHA256SUMS.txt",
|
|
)
|
|
REQUIRED_REAL_SYMBOLS = {
|
|
"rarch_main",
|
|
"retroarch_main_init",
|
|
"runloop_iterate",
|
|
"retro_init",
|
|
"retro_deinit",
|
|
"retro_run",
|
|
"frontend_ctx_ps5",
|
|
}
|
|
DISABLED_FEATURES = {
|
|
"networking",
|
|
"online_updater",
|
|
"dynamic_cores",
|
|
"autoload",
|
|
"installation",
|
|
"gnm",
|
|
"opengl",
|
|
"vulkan",
|
|
}
|
|
FORBIDDEN_MARKERS = (
|
|
"chimera_lifecycle_probe",
|
|
"prospero-deploy",
|
|
"payload_sender",
|
|
"sceSystemServiceLoadExec",
|
|
)
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
value = json.load(stream)
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{path} is not a JSON object")
|
|
return value
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def artifact_execution_allowed(record: dict[str, Any]) -> bool:
|
|
return bool(record.get("execution_eligible", False))
|
|
|
|
|
|
def route_is_forbidden(text: str) -> bool:
|
|
lowered = text.lower()
|
|
return any(marker.lower() in lowered for marker in FORBIDDEN_MARKERS)
|
|
|
|
|
|
def profile_is_closed(profile: dict[str, Any]) -> bool:
|
|
disabled = set(profile.get("disabled_features", []))
|
|
return (
|
|
DISABLED_FEATURES.issubset(disabled)
|
|
and profile.get("automatic_retry") is False
|
|
and profile.get("filesystem_writes_on_default_path") is False
|
|
)
|
|
|
|
|
|
def is_real_retroarch_artifact(record: dict[str, Any]) -> bool:
|
|
return REQUIRED_REAL_SYMBOLS.issubset(set(record.get("required_symbols", [])))
|
|
|
|
|
|
def has_personal_path(strings: list[str]) -> bool:
|
|
patterns = (
|
|
re.compile(r"[A-Za-z]:\\Users\\", re.IGNORECASE),
|
|
re.compile(r"/(?:mnt/[a-z]/)?Users/[^/]+/", re.IGNORECASE),
|
|
re.compile(r"/home/[^/]+/", re.IGNORECASE),
|
|
)
|
|
return any(pattern.search(value) for value in strings for pattern in patterns)
|
|
|
|
|
|
def has_ip_address(strings: list[str]) -> bool:
|
|
pattern = re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)")
|
|
return any(pattern.search(value) for value in strings)
|
|
|
|
|
|
def validate_artifact(record: dict[str, Any]) -> list[str]:
|
|
errors: list[str] = []
|
|
name = record.get("name", "<unnamed>")
|
|
if artifact_execution_allowed(record):
|
|
errors.append(f"{name}: execution_eligible must be false")
|
|
if record.get("target_execution_performed") is not False:
|
|
errors.append(f"{name}: target execution state is not false")
|
|
if record.get("reproducibility") != "BYTE_IDENTICAL_TWO_CLEAN_BUILDS":
|
|
errors.append(f"{name}: reproducibility is not byte exact")
|
|
if not is_real_retroarch_artifact(record):
|
|
errors.append(f"{name}: real RetroArch symbols are incomplete")
|
|
if len(record.get("sha256", "")) != 64 or record.get("size", 0) <= 0:
|
|
errors.append(f"{name}: invalid size/hash identity")
|
|
if not record.get("linker_map_sha256"):
|
|
errors.append(f"{name}: linker map identity is missing")
|
|
if not isinstance(record.get("undefined_symbols"), list):
|
|
errors.append(f"{name}: undefined-symbol inventory is missing")
|
|
if record.get("embedded_personal_paths") is not False:
|
|
errors.append(f"{name}: personal path audit did not pass")
|
|
if not isinstance(record.get("embedded_ip_addresses"), list):
|
|
errors.append(f"{name}: IP-address inventory is missing")
|
|
if record.get("embedded_device_ip_addresses") is not False:
|
|
errors.append(f"{name}: a device/private address is embedded")
|
|
if record.get("forbidden_markers_found"):
|
|
errors.append(f"{name}: forbidden marker found")
|
|
return errors
|
|
|
|
|
|
def git(root: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode:
|
|
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def validate(root: Path, retroarch_root: Path | None = None) -> list[str]:
|
|
errors: list[str] = []
|
|
for relative in DELIVERABLES:
|
|
if not (root / relative).is_file():
|
|
errors.append(f"missing deliverable: {relative}")
|
|
|
|
try:
|
|
build = load_json(root / "manifests/retroarch/phase-1.0a-build.json")
|
|
artifacts_doc = load_json(
|
|
root / "manifests/retroarch/phase-1.0a-artifacts.json"
|
|
)
|
|
upstreams = load_json(root / "manifests/retroarch/upstreams.json")
|
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
return errors + [str(error)]
|
|
|
|
if build.get("phase") != PHASE or build.get("status") != STATUS:
|
|
errors.append("phase/status mismatch")
|
|
if build.get("baseline_commit") != BASELINE:
|
|
errors.append("baseline mismatch")
|
|
if build.get("branch") != BRANCH:
|
|
errors.append("branch mismatch")
|
|
for field in OFFLINE_AUTHORIZATION_FIELDS:
|
|
if build.get(field) is not True:
|
|
errors.append(f"{field} must record the explicit offline grant")
|
|
for field in DEVICE_AUTHORIZATION_FIELDS:
|
|
if build.get(field) is not False:
|
|
errors.append(f"{field} must remain false")
|
|
for field in (
|
|
"ps5_connected",
|
|
"device_request_performed",
|
|
"files_transferred",
|
|
"device_write_performed",
|
|
"target_execution_performed",
|
|
"install_package_created",
|
|
"execution_package_created",
|
|
):
|
|
if build.get(field) is not False:
|
|
errors.append(f"{field} must remain false")
|
|
|
|
fork = build.get("chimera_retroarch", {})
|
|
if fork.get("branch") != RETROARCH_BRANCH or len(fork.get("commit", "")) != 40:
|
|
errors.append("private fork identity is incomplete")
|
|
if fork.get("working_tree_clean") is not True:
|
|
errors.append("private fork was not clean at final build")
|
|
|
|
profiles = build.get("profiles", {})
|
|
for name in (
|
|
"ps5-headless-smokecore",
|
|
"ps5-software-rgui-smokecore",
|
|
"host-smokecore-integration",
|
|
):
|
|
if name not in profiles or not profile_is_closed(profiles[name]):
|
|
errors.append(f"profile is not closed: {name}")
|
|
|
|
host = build.get("host_tests", {})
|
|
if (
|
|
host.get("frames") != 600
|
|
or host.get("video_fnv1a64") != "43f920496eb5f435"
|
|
or host.get("audio_fnv1a64") != "a48f47dc08c56625"
|
|
or host.get("asan_ubsan") != "PASS"
|
|
or host.get("input_mapping") != "PASS"
|
|
or host.get("clean_shutdown") != "PASS"
|
|
):
|
|
errors.append("host smoke-core evidence mismatch")
|
|
|
|
expected_upstreams = {
|
|
"retroarch": RETROARCH_COMMIT,
|
|
"ps5_payload_sdk": SDK_COMMIT,
|
|
"ps5_sdl": SDL_COMMIT,
|
|
"pacbrew": PACBREW_COMMIT,
|
|
}
|
|
for name, commit in expected_upstreams.items():
|
|
if upstreams.get("sources", {}).get(name, {}).get("commit") != commit:
|
|
errors.append(f"upstream mismatch: {name}")
|
|
retroarch = upstreams.get("sources", {}).get("retroarch", {})
|
|
if (
|
|
retroarch.get("tag") != RETROARCH_TAG
|
|
or retroarch.get("tree") != RETROARCH_TREE
|
|
or retroarch.get("source_archive_sha256") != RETROARCH_ARCHIVE_SHA256
|
|
):
|
|
errors.append("RetroArch release provenance mismatch")
|
|
|
|
records = artifacts_doc.get("artifacts", [])
|
|
if {item.get("name") for item in records} != {
|
|
"retroarch_ps5_headless.elf",
|
|
"retroarch_ps5_software.elf",
|
|
}:
|
|
errors.append("artifact set mismatch")
|
|
for record in records:
|
|
errors.extend(validate_artifact(record))
|
|
|
|
denylist = root / "manifests/artifact-denylist.json"
|
|
if not denylist.is_file() or sha256_file(denylist) != DENYLIST_SHA256:
|
|
errors.append("permanent denylist changed")
|
|
|
|
tracked = git(root, "ls-files").splitlines()
|
|
forbidden_suffixes = (".elf", ".self", ".sprx", ".pkg")
|
|
for relative in tracked:
|
|
if relative.lower().endswith(forbidden_suffixes):
|
|
errors.append(f"tracked target artifact: {relative}")
|
|
new_text = "\n".join(
|
|
(root / relative).read_text(encoding="utf-8", errors="replace")
|
|
for relative in DELIVERABLES
|
|
if (root / relative).is_file()
|
|
)
|
|
unfinished_template = "{" * 2
|
|
unfinished_label = "".join(("T", "B", "D"))
|
|
if unfinished_template in new_text or unfinished_label in new_text:
|
|
errors.append("unfinished placeholder in deliverables")
|
|
|
|
if retroarch_root is not None:
|
|
for record in records:
|
|
path = retroarch_root / record["local_relative_path"]
|
|
if not path.is_file():
|
|
errors.append(f"local artifact missing: {path}")
|
|
continue
|
|
if path.stat().st_size != record["size"] or sha256_file(path) != record[
|
|
"sha256"
|
|
]:
|
|
errors.append(f"local artifact identity mismatch: {path.name}")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
parser.add_argument("--retroarch-root", type=Path)
|
|
args = parser.parse_args()
|
|
errors = validate(
|
|
args.root.resolve(),
|
|
args.retroarch_root.resolve() if args.retroarch_root else None,
|
|
)
|
|
if errors:
|
|
for error in errors:
|
|
print(error, file=sys.stderr)
|
|
return 1
|
|
print("Phase 1.0A RetroArch evidence validated")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|