From 94f03775c02a0efef027a4dee2c430a1919c5fc5 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Mon, 7 Sep 2026 16:09:46 +0300 Subject: [PATCH 1/2] Check BIOS assets selected by the setup recipe --- docs/SETUP_BIOS_ASSETS.md | 11 +++++ runtime/CMakeLists.txt | 1 + runtime/tests/test_setup_bios_assets.py | 55 +++++++++++++++++++++++++ tools/check_setup_bios_assets.py | 46 +++++++++++++++++++++ tools/stage_setup_sdk.sh | 15 ++++--- 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 docs/SETUP_BIOS_ASSETS.md create mode 100644 runtime/tests/test_setup_bios_assets.py create mode 100644 tools/check_setup_bios_assets.py diff --git a/docs/SETUP_BIOS_ASSETS.md b/docs/SETUP_BIOS_ASSETS.md new file mode 100644 index 000000000..fdda50e20 --- /dev/null +++ b/docs/SETUP_BIOS_ASSETS.md @@ -0,0 +1,11 @@ +# Setup SDK BIOS assets + +The staged game.toml selects the required BIOS profile. Retail-only recipes do not require the unused OpenBIOS image and license. +Default OpenBIOS recipes still require their profile, image and license. The selected profile must be a file inside the staged kit. +The gate does not bundle retail BIOS dumps and does not change the CLI's BIOS selection or generated-code readiness checks. + +Run `python runtime/tests/test_setup_bios_assets.py` for five synthetic fixture tests. +The existing `stage_setup_sdk.sh` gate calls the same production checker. Python 3.11 uses tomllib; older Python needs tomli, as other setup tools do. + +This gate is independently useful for retail SCPH1001 setup. Full selected-profile/SCPH5552 support also needs matching CLI, profile and generation support. +The current upstream CLI still hardcodes SCPH1001 in its retail generate path. Passing this gate alone does not establish SCPH5552 setup support. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 21ae85a5a..8872a2fcf 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -562,6 +562,7 @@ if(BUILD_TESTING) find_package(Python3 COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + add_test(NAME setup_bios_assets_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_setup_bios_assets.py) add_test(NAME disc_companion_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_disc_companion.py) add_test(NAME sbi_registry_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_sbi_registry.py) add_test(NAME frame_interpolation_context_ownership_test diff --git a/runtime/tests/test_setup_bios_assets.py b/runtime/tests/test_setup_bios_assets.py new file mode 100644 index 000000000..bb587163f --- /dev/null +++ b/runtime/tests/test_setup_bios_assets.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Exercise the production SDK BIOS gate with synthetic files only.""" +import importlib.util +import tempfile +import unittest +from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("gate", ROOT / "tools/check_setup_bios_assets.py") +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class GateTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + (self.root / "psxrecomp/bios").mkdir(parents=True) + + def recipe(self, text): + (self.root / "game.toml").write_text(text) + + def asset(self, name): + (self.root / "psxrecomp/bios" / name).write_text("synthetic fixture") + + def test_retail_profile_without_openbios(self): + self.recipe('[runtime]\nopenbios=false\n[recompiler]\nbios_config="psxrecomp/bios/SCPH5552.toml"\n') + self.asset("SCPH5552.toml") + self.assertEqual(gate.check(self.root), [str(Path("psxrecomp/bios/SCPH5552.toml"))]) + + def test_retail_missing_profile_fails(self): + self.recipe('[runtime]\nopenbios=false\n[recompiler]\nbios_config="psxrecomp/bios/SCPH5552.toml"\n') + with self.assertRaisesRegex(ValueError, "SCPH5552"): + gate.check(self.root) + + def test_default_openbios_requires_all_assets(self): + self.recipe('[runtime]\n') + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + with self.assertRaises(ValueError): + gate.check(self.root) + self.asset(name) + self.assertEqual(len(gate.check(self.root)), 3) + + def test_external_profile_fails(self): + self.recipe('[runtime]\nopenbios=false\n[recompiler]\nbios_config="../external.toml"\n') + with self.assertRaisesRegex(ValueError, "inside"): + gate.check(self.root) + + def test_absent_recipe_fails(self): + with self.assertRaisesRegex(ValueError, "game.toml"): + gate.check(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_setup_bios_assets.py b/tools/check_setup_bios_assets.py new file mode 100644 index 000000000..a9bf1afab --- /dev/null +++ b/tools/check_setup_bios_assets.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Require BIOS assets selected by the staged title recipe.""" +import sys +from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +def check(stage): + stage = Path(stage).resolve() + recipe = stage / "game.toml" + if not recipe.is_file(): + raise ValueError("missing staged game.toml for BIOS policy") + with recipe.open("rb") as handle: + config = tomllib.load(handle) + openbios = config.get("runtime", {}).get("openbios", True) + if not isinstance(openbios, bool): + raise ValueError("runtime.openbios must be a boolean") + profile = config.get("recompiler", {}).get("bios_config") + required = [] + if profile: + path = (stage / profile).resolve() + try: + path.relative_to(stage) + except ValueError: + raise ValueError("BIOS profile must remain inside the staged kit") + required.append(path) + elif not openbios: + # Existing title recipes without a profile use the CLI default. + required.append(stage / "psxrecomp/bios/SCPH1001.toml") + if openbios: + required.extend(stage / "psxrecomp/bios" / name for name in + ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE")) + for path in required: + if not path.is_file(): + raise ValueError("missing staged BIOS asset: " + str(path.relative_to(stage))) + return [str(path.relative_to(stage)) for path in required] + + +if __name__ == "__main__": + try: + print("staged BIOS policy: " + ", ".join(check(sys.argv[1]))) + except (ValueError, OSError) as error: + sys.exit("error: " + str(error)) diff --git a/tools/stage_setup_sdk.sh b/tools/stage_setup_sdk.sh index e22310a57..4dd91b854 100755 --- a/tools/stage_setup_sdk.sh +++ b/tools/stage_setup_sdk.sh @@ -201,12 +201,15 @@ cat >"${STAGE}/psxrecomp/retcomm-sdk.json" <<'EOF' } EOF -for f in OpenBIOS.toml openbios.bin OpenBIOS.LICENSE SCPH1001.toml; do - if [[ ! -f "${STAGE}/psxrecomp/bios/${f}" ]]; then - echo "error: missing psxrecomp/bios/${f} in staged tree" >&2 - exit 1 - fi -done +if command -v python3 >/dev/null 2>&1; then + SDK_PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + SDK_PYTHON=python +else + echo "error: Python is required to check the staged BIOS policy" >&2 + exit 1 +fi +"${SDK_PYTHON}" "${SCRIPT_DIR}/check_setup_bios_assets.py" "${STAGE}" if [[ "${REQUIRE_CLI}" -eq 1 && ! -f "${STAGE}/psxrecomp/psxrecomp_cli.py" ]]; then echo "error: missing psxrecomp/psxrecomp_cli.py in staged tree" >&2 From 406451cb92da4d9f701c81b8271e420cef8677cc Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Mon, 7 Sep 2026 17:17:30 +0300 Subject: [PATCH 2/2] Check resolved paths for every staged BIOS asset --- docs/SETUP_BIOS_ASSETS.md | 4 +- runtime/tests/test_setup_bios_assets.py | 67 +++++++++++++++++++++++++ tools/check_setup_bios_assets.py | 12 ++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/SETUP_BIOS_ASSETS.md b/docs/SETUP_BIOS_ASSETS.md index fdda50e20..b79862856 100644 --- a/docs/SETUP_BIOS_ASSETS.md +++ b/docs/SETUP_BIOS_ASSETS.md @@ -4,7 +4,9 @@ The staged game.toml selects the required BIOS profile. Retail-only recipes do n Default OpenBIOS recipes still require their profile, image and license. The selected profile must be a file inside the staged kit. The gate does not bundle retail BIOS dumps and does not change the CLI's BIOS selection or generated-code readiness checks. -Run `python runtime/tests/test_setup_bios_assets.py` for five synthetic fixture tests. +Every required asset is resolved before the containment check, including default profiles and OpenBIOS assets. A file or parent-directory symlink cannot make an external asset count as part of the kit. Symlinks whose targets remain inside the kit are allowed. + +Run `python runtime/tests/test_setup_bios_assets.py` for ten synthetic fixture tests. Symlink controls run when the host permits symlink creation; other controls always run. The existing `stage_setup_sdk.sh` gate calls the same production checker. Python 3.11 uses tomllib; older Python needs tomli, as other setup tools do. This gate is independently useful for retail SCPH1001 setup. Full selected-profile/SCPH5552 support also needs matching CLI, profile and generation support. diff --git a/runtime/tests/test_setup_bios_assets.py b/runtime/tests/test_setup_bios_assets.py index bb587163f..2adde84de 100644 --- a/runtime/tests/test_setup_bios_assets.py +++ b/runtime/tests/test_setup_bios_assets.py @@ -50,6 +50,73 @@ def test_absent_recipe_fails(self): with self.assertRaisesRegex(ValueError, "game.toml"): gate.check(self.root) + def symlink(self, link, target, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except (OSError, NotImplementedError) as error: + self.skipTest("symlink creation unavailable: " + str(error)) + + def outside(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return Path(tmp.name) + + def test_default_retail_rejects_external_profile_symlink(self): + self.recipe('[runtime]\nopenbios=false\n') + target = self.outside() / "SCPH1001.toml" + target.write_text("synthetic fixture") + self.symlink(self.root / "psxrecomp/bios/SCPH1001.toml", target) + with self.assertRaisesRegex(ValueError, "inside"): + gate.check(self.root) + + def test_openbios_rejects_external_asset_symlinks(self): + self.recipe('[runtime]\n') + names = ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE") + for name in names: + self.asset(name) + target = self.outside() / "asset" + target.write_text("synthetic fixture") + for name in names: + with self.subTest(asset=name): + link = self.root / "psxrecomp/bios" / name + link.unlink() + self.symlink(link, target) + with self.assertRaisesRegex(ValueError, "inside"): + gate.check(self.root) + link.unlink() + self.asset(name) + + def test_default_retail_rejects_external_bios_directory(self): + self.recipe('[runtime]\nopenbios=false\n') + target = self.outside() + (target / "SCPH1001.toml").write_text("synthetic fixture") + link = self.root / "psxrecomp/bios" + link.rmdir() + self.symlink(link, target, directory=True) + with self.assertRaisesRegex(ValueError, "inside"): + gate.check(self.root) + + def test_openbios_rejects_external_framework_directory(self): + self.recipe('[runtime]\n') + target = self.outside() + (target / "bios").mkdir() + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + (target / "bios" / name).write_text("synthetic fixture") + (self.root / "psxrecomp/bios").rmdir() + link = self.root / "psxrecomp" + link.rmdir() + self.symlink(link, target, directory=True) + with self.assertRaisesRegex(ValueError, "inside"): + gate.check(self.root) + + def test_internal_asset_symlinks_are_accepted(self): + self.recipe('[runtime]\n') + target = self.root / "shared-asset" + target.write_text("synthetic fixture") + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + self.symlink(self.root / "psxrecomp/bios" / name, target) + self.assertEqual(len(gate.check(self.root)), 3) + if __name__ == "__main__": unittest.main() diff --git a/tools/check_setup_bios_assets.py b/tools/check_setup_bios_assets.py index a9bf1afab..fb5341346 100644 --- a/tools/check_setup_bios_assets.py +++ b/tools/check_setup_bios_assets.py @@ -21,12 +21,7 @@ def check(stage): profile = config.get("recompiler", {}).get("bios_config") required = [] if profile: - path = (stage / profile).resolve() - try: - path.relative_to(stage) - except ValueError: - raise ValueError("BIOS profile must remain inside the staged kit") - required.append(path) + required.append(stage / profile) elif not openbios: # Existing title recipes without a profile use the CLI default. required.append(stage / "psxrecomp/bios/SCPH1001.toml") @@ -34,6 +29,11 @@ def check(stage): required.extend(stage / "psxrecomp/bios" / name for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE")) for path in required: + path = path.resolve() + try: + path.relative_to(stage) + except ValueError: + raise ValueError("BIOS asset must remain inside the staged kit") if not path.is_file(): raise ValueError("missing staged BIOS asset: " + str(path.relative_to(stage))) return [str(path.relative_to(stage)) for path in required]