diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index acd0d9d1..dec25b94 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -492,9 +492,9 @@ static int find_python(char* out, size_t cap) { snprintf(out, cap, "%s", env); return 1; } +#if defined(_WIN32) if (find_toolchain_python(out, cap)) return 1; -#if defined(_WIN32) /* Prefer python.org / py-launcher installs over the Microsoft Store * stub: Store Python redirects LocalAppData writes into LocalCache. */ char resolved[1100]; @@ -799,6 +799,10 @@ static int resolve_toolchain_bin(char* out, size_t cap) { static void activate_toolchain_path(void) { char pack_root[1400]; g_toolchain_bin[0] = '\0'; +#if !defined(_WIN32) + /* A cached portable pack must not shadow Linux/macOS native tools. */ + return; +#endif if (!resolve_toolchain_bin(g_toolchain_bin, sizeof(g_toolchain_bin))) return; /* Pack root (parent of bin/) — Windows cmake-clang-v1 ships zlib here. */ @@ -907,6 +911,9 @@ static int find_cmake(char* out, size_t cap) { snprintf(out, cap, "%s", env); return 1; } +#if !defined(_WIN32) + return find_on_path("cmake", out, cap); +#endif char tc[1100], cand[1200]; if (resolve_toolchain_bin(tc, sizeof(tc))) { #if defined(_WIN32) @@ -3064,6 +3071,44 @@ static int cmake_path_runs(const char* cmake_path) { return run_cmd_exit_zero(cmd); } +#if !defined(_WIN32) +/* Native release kits probe host tools; setup never installs system packages. */ +static int posix_command_runs(const char* command) { + char cmd[1600]; + if (!command || !command[0]) + return 0; + snprintf(cmd, sizeof(cmd), "\"%s\" --version >/dev/null 2>&1", command); + return run_cmd_exit_zero(cmd); +} + +static int host_system_toolchain_ready(void) { + char python[1200], tool[1200]; +#if defined(__APPLE__) + /* Avoid xcrun prompting to install tools during a readiness check. */ + if (!run_cmd_exit_zero("xcode-select -p >/dev/null 2>&1")) + return 0; +#endif + if (!find_on_path("cmake", g_cmake, sizeof(g_cmake)) || + !posix_command_runs(g_cmake)) + return 0; + if (!(find_on_path("python3", python, sizeof(python)) || + find_on_path("python", python, sizeof(python))) || + !posix_command_runs(python)) + return 0; + if (!find_on_path("ninja", tool, sizeof(tool)) || !posix_command_runs(tool)) + return 0; + if (!(find_on_path("cc", tool, sizeof(tool)) || + find_on_path("gcc", tool, sizeof(tool)) || + find_on_path("clang", tool, sizeof(tool))) || !posix_command_runs(tool)) + return 0; + if (!(find_on_path("c++", tool, sizeof(tool)) || + find_on_path("g++", tool, sizeof(tool)) || + find_on_path("clang++", tool, sizeof(tool))) || !posix_command_runs(tool)) + return 0; + return 1; +} +#endif + /* True when pack bin/ can compile+link a tiny C program (catches broken * ld.lld / missing libicuuc.so.* etc. that cmake --version still passes). */ static int toolchain_bin_compiler_works(const char* bin) { @@ -3367,6 +3412,9 @@ static int host_toolchain_is_ready(void) { if (!g_project_root[0]) return 0; g_tc_repair_note[0] = '\0'; +#if !defined(_WIN32) + return host_system_toolchain_ready(); +#endif migrate_legacy_psxrecomp_toolchain(); /* Wizard open: drop broken latest/ before treating the pack as ready. */ heal_broken_toolchain_pointers(); @@ -3538,6 +3586,9 @@ static int host_toolchain_update_available(char* local_ver, size_t local_cap, local_ver[0] = '\0'; if (remote_ver && remote_cap) remote_ver[0] = '\0'; +#if !defined(_WIN32) + return 0; +#endif if (skip && skip[0] && skip[0] != '0') return 0; if (!g_project_root[0]) @@ -3568,6 +3619,16 @@ static int host_ensure_toolchain_with_progress( snprintf(err_msg, err_cap, "Project root is not available."); return 0; } +#if !defined(_WIN32) + if (on_progress) + on_progress(progress_ctx, 0.02f, "Checking native build tools…"); + if (host_system_toolchain_ready()) + return 1; + snprintf(err_msg, err_cap, + "Native build tools are missing. Install CMake, Ninja, Python 3, " + "and C/C++ compilers with your package manager, then check tools again."); + return 0; +#endif migrate_legacy_psxrecomp_toolchain(); activate_toolchain_path(); if (!force && host_portable_cmake_ready()) diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index 1d94932e..d2f9ae78 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -62,6 +62,8 @@ def activate_embedded_toolchain( project_root: Path, progress: Optional[ProgressReporter] = None ) -> bool: """Prepend resolved toolchain bin/ to PATH for cmake/ninja/clang.""" + if sys.platform != "win32": + return False log = progress.log if progress else None bin_dir = resolve_toolchain_bin(project_root) if not bin_dir or not toolchain_bin_runs(bin_dir, log=log): diff --git a/runtime/tests/test_setup_bios_assets.py b/runtime/tests/test_setup_bios_assets.py index 9e6e5705..5bad5eb9 100644 --- a/runtime/tests/test_setup_bios_assets.py +++ b/runtime/tests/test_setup_bios_assets.py @@ -1,16 +1,26 @@ #!/usr/bin/env python3 """Exercise the production SDK BIOS gate with synthetic files only.""" import importlib.util +import ast +import types import os import shutil import subprocess import tempfile import unittest +from unittest import mock +import zipfile 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) +spec = importlib.util.spec_from_file_location("platform_copy", ROOT / "tools/ci/audit_setup_package_platform_copy.py") +platform_copy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform_copy) +spec = importlib.util.spec_from_file_location("toolchain_pack", ROOT / "tools/toolchain_pack.py") +toolchain_pack = importlib.util.module_from_spec(spec) +spec.loader.exec_module(toolchain_pack) class GateTests(unittest.TestCase): @@ -152,7 +162,9 @@ class SetupPackageTests(unittest.TestCase): self.write("CMakeLists.txt", "# synthetic title\n") self.write("VERSION", "0.0.1\n") self.write("build/psx_game_version.txt", "0.0.1\n") - self.write("build/Fixture", "synthetic executable, never run\n") + self.write("build/Fixture", "synthetic executable, never run\n" + "1. Native build tools\n" + "Install CMake, Ninja, Python 3, and either Clang or GCC\nCheck tools\n") self.write("build/replay-diagnostics.py", "# staged replay helper\n") # Packaging tests exercise staging, not a system compression utility. # Ubuntu developer images may omit zip; write a real ZIP with stdlib. @@ -174,7 +186,7 @@ with zipfile.ZipFile(sys.argv[-2], "w") as archive: self.write("game.toml", "[runtime]\noverlay_cache=true\nopenbios=" + ("false" if retail else "true") + "\n") args = ["bash", str(ROOT / "tools/package_setup_host.sh"), - "--root", str(self.root), "--build-dir", "build", "--artifact", "fixture", + "--root", str(self.root), "--build-dir", "build", "--artifact", "linux-x64", "--zip-prefix", "test", "--exe-name", "Fixture", "--recompiler-build", "emitters", "--project-file", "CMakeLists.txt", "--project-file", "game.toml"] if retail: @@ -188,12 +200,16 @@ with zipfile.ZipFile(sys.argv[-2], "w") as archive: with self.subTest(retail=retail): result = self.package(retail) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - stage = self.root / "dist/stage-setup-fixture" + stage = self.root / "dist/stage-setup-linux-x64" self.assertTrue((stage / "replay-diagnostics.py").is_file()) self.assertTrue((stage / "psxrecomp/bios/SCPH1001.toml").is_file()) for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): self.assertEqual((stage / "psxrecomp/bios" / name).exists(), not retail) readme = (stage / "README-SETUP.txt").read_text() + self.assertIn("Install CMake, Ninja, Python 3", readme) + self.assertIn("native build tools", readme) + self.assertNotIn("cmake-clang-v1", readme) + self.assertEqual(platform_copy.audit(self.root / "dist/test-0.0.1-linux-x64.zip")["status"], "pass") if retail: self.assertIn("a legal retail BIOS dump (required)", readme) self.assertNotIn("otherwise OpenBIOS", readme) @@ -216,5 +232,96 @@ with zipfile.ZipFile(sys.argv[-2], "w") as archive: self.assertIn("requires runtime.openbios=false", result.stderr) +class PlatformCopyTests(unittest.TestCase): + def test_gate_requires_real_platform_copy_and_controls(self): + with tempfile.TemporaryDirectory() as tmp: + for platform in ("windows-x64", "linux-x64", "macos-arm64", "macos-x64"): + windows = platform.startswith("windows") + host = "Fixture.exe" if windows else "Fixture" + readme = "1. Run {}.\n2. Provide a legal retail BIOS (required).\n".format(host) + if windows: + readme += "downloads cmake-clang-v1 toolchain pack\n" + controls = "Download latest portable toolchain Select toolchain zip cmake-clang-v1" + else: + readme += "Install CMake, Ninja, Python 3 and a C/C++ compiler; available on PATH; native build tools\n" + controls = "1. Native build tools Install CMake, Ninja, Python 3, and either Clang or GCC Check tools" + archive = Path(tmp) / ("setup-" + platform + ".zip") + + def write(extra=""): + with zipfile.ZipFile(archive, "w") as output: + output.writestr("README-SETUP.txt", readme) + output.writestr(host, controls + extra) + + write() + self.assertEqual(platform_copy.audit(archive)["status"], "pass") + if not windows: + write("1. Portable toolchain") + with self.assertRaisesRegex(ValueError, "forbidden"): + platform_copy.audit(archive) + + +class NativeToolchainTests(unittest.TestCase): + def test_cli_does_not_activate_cached_packs_on_posix(self): + tree = ast.parse((ROOT / "psxrecomp_cli.py").read_text(encoding="utf-8")) + function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) + and node.name == "activate_embedded_toolchain") + function.returns = None + for argument in function.args.args: + argument.annotation = None + namespace = {"sys": types.SimpleNamespace(platform="linux"), + "resolve_toolchain_bin": mock.Mock(side_effect=AssertionError("cached pack"))} + exec(compile(ast.Module(body=[function], type_ignores=[]), "cli_activation", "exec"), namespace) + self.assertFalse(namespace[function.name](Path("."))) + namespace["resolve_toolchain_bin"].assert_not_called() + + def test_host_native_guards_precede_cache_and_download_paths(self): + source = (ROOT / "host/psxrecomp_codegen_host.c").read_text(encoding="utf-8") + for start, native, portable in ( + ("static void activate_toolchain_path(void)", "return;", "if (!resolve_toolchain_bin"), + ("static int host_toolchain_is_ready(void)", "return host_system_toolchain_ready();", "migrate_legacy_psxrecomp_toolchain();"), + ("static int host_ensure_toolchain_with_progress(", "if (host_system_toolchain_ready())", "migrate_legacy_psxrecomp_toolchain();")): + body = source[source.index(start):] + self.assertLess(body.index("#if !defined(_WIN32)"), body.index(native)) + self.assertLess(body.index(native), body.index(portable)) + body = source[source.index("static int host_system_toolchain_ready(void)"): + source.index("/* True when pack bin/ can compile+link")] + for tool in ("cmake", "ninja", "python3", "cc", "c++"): + self.assertIn('find_on_path("' + tool + '"', body) + self.assertIn("xcode-select -p", body) + + def test_posix_uses_native_probes_and_never_touches_pack_cache_or_network(self): + for platform in ("linux", "darwin"): + with self.subTest(platform=platform), \ + mock.patch.object(toolchain_pack.sys, "platform", platform), \ + mock.patch.object(toolchain_pack.shutil, "which", side_effect=lambda name: "/native/bin/" + name), \ + mock.patch.object(toolchain_pack.subprocess, "run", return_value=types.SimpleNamespace(returncode=0)) as probe, \ + mock.patch.object(toolchain_pack, "migrate_legacy_psxrecomp_cache", side_effect=AssertionError("touched cache")): + self.assertEqual(toolchain_pack.ensure_toolchain(download=True), Path("/native/bin")) + self.assertEqual(len(probe.call_args_list), 5) + self.assertTrue(all(call.args[0][-1] == "--version" for call in probe.call_args_list)) + + def test_posix_missing_and_broken_tools_fail_without_download(self): + for broken in (False, True): + with self.subTest(broken=broken), \ + mock.patch.object(toolchain_pack, "sys_platform_is_windows", return_value=False), \ + mock.patch.object(toolchain_pack.shutil, "which", return_value="/native/cmake" if broken else None), \ + mock.patch.object(toolchain_pack.subprocess, "run", return_value=types.SimpleNamespace(returncode=1)), \ + mock.patch.object(toolchain_pack, "migrate_legacy_psxrecomp_cache", side_effect=AssertionError("touched cache")): + with self.assertRaisesRegex(RuntimeError, "package manager"): + toolchain_pack.ensure_toolchain(download=True) + + def test_windows_retains_existing_portable_pack_activation(self): + binary = Path("fixture-pack/bin") + with mock.patch.object(toolchain_pack, "sys_platform_is_windows", return_value=True), \ + mock.patch.object(toolchain_pack, "is_windows_store_python", return_value=False), \ + mock.patch.object(toolchain_pack, "migrate_legacy_psxrecomp_cache"), \ + mock.patch.object(toolchain_pack, "resolve_toolchain_bin", return_value=binary), \ + mock.patch.object(toolchain_pack, "toolchain_bin_runs", return_value=True), \ + mock.patch.object(toolchain_pack, "register_toolchain_user_env"), \ + mock.patch.object(toolchain_pack, "activate_toolchain_bin") as activate: + self.assertEqual(toolchain_pack.ensure_toolchain(download=True), binary) + activate.assert_called_once_with(binary, log=None) + + if __name__ == "__main__": unittest.main() diff --git a/tools/ci/audit_setup_package_platform_copy.py b/tools/ci/audit_setup_package_platform_copy.py new file mode 100644 index 00000000..f5c9b3ee --- /dev/null +++ b/tools/ci/audit_setup_package_platform_copy.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Reject setup ZIPs whose toolchain copy does not match the host platform.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path, PurePosixPath + + +PLATFORM_MARKERS = { + "windows": ("windows-x64",), + "linux": ("linux-x64",), + "macos": ("macos-x64", "macos-arm64"), +} + + +def sha256(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().upper() + + +def infer_platform(path: Path) -> str: + lower = path.name.lower() + matches = [ + platform + for platform, markers in PLATFORM_MARKERS.items() + if any(marker in lower for marker in markers) + ] + if len(matches) != 1: + raise ValueError( + "archive name must contain exactly one supported platform marker: " + "windows-x64, linux-x64, macos-x64, or macos-arm64" + ) + return matches[0] + + +def root_member(names: list[str], basename: str) -> str: + matches = [ + name + for name in names + if not name.endswith("/") + and len(PurePosixPath(name).parts) == 1 + and PurePosixPath(name).name.lower() == basename.lower() + ] + if len(matches) != 1: + raise ValueError(f"archive must contain exactly one root {basename}") + return matches[0] + + +def require(text: str, tokens: tuple[str, ...], where: str) -> None: + missing = [token for token in tokens if token not in text] + if missing: + raise ValueError(f"{where} is missing: {', '.join(missing)}") + + +def reject(text: str, tokens: tuple[str, ...], where: str) -> None: + found = [token for token in tokens if token in text] + if found: + raise ValueError(f"{where} contains forbidden copy: {', '.join(found)}") + + +def audit(path: Path) -> dict[str, object]: + platform = infer_platform(path) + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + readme_name = root_member(names, "README-SETUP.txt") + readme = archive.read(readme_name).decode("utf-8") + + run_match = re.search(r"(?m)^\s*\d+\.\s+Run\s+([^\s.]+(?:\.exe)?)\.\s*$", readme) + if not run_match: + raise ValueError("README-SETUP.txt has no numbered Run instruction") + host_name = run_match.group(1) + if platform == "windows" and not host_name.lower().endswith(".exe"): + host_name += ".exe" + host_member = root_member(names, host_name) + host_copy = archive.read(host_member).decode("latin-1") + + if platform == "windows": + require( + readme, + ("downloads", "cmake-clang-v1", "toolchain pack"), + "Windows README-SETUP.txt", + ) + require( + host_copy, + ( + "Download latest portable toolchain", + "Select toolchain zip", + "cmake-clang-v1", + ), + "Windows setup host", + ) + else: + require( + readme, + ( + "Install CMake, Ninja, Python 3", + "C/C++ compiler", + "available on PATH", + "native build tools", + ), + "POSIX README-SETUP.txt", + ) + reject( + readme.lower(), + ("cmake-clang-v1", "portable toolchain", "toolchain pack", "toolchain zip"), + "POSIX README-SETUP.txt", + ) + require( + host_copy, + ( + "1. Native build tools", + "Install CMake, Ninja, Python 3, and either Clang or GCC", + "Check tools", + ), + "POSIX setup host", + ) + reject( + host_copy, + ("Download latest portable toolchain##tc", "1. Portable toolchain"), + "POSIX setup host", + ) + + return { + "archive": str(path.resolve()), + "platform": platform, + "sha256": sha256(path), + "size": path.stat().st_size, + "status": "pass", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archives", nargs="+", type=Path) + parser.add_argument("--json", type=Path, dest="json_path") + args = parser.parse_args() + + results: list[dict[str, object]] = [] + failed = False + for path in args.archives: + try: + result = audit(path) + except (OSError, ValueError, zipfile.BadZipFile) as error: + result = {"archive": str(path.resolve()), "status": "fail", "error": str(error)} + failed = True + results.append(result) + print(f"{result['status'].upper()}: {path}: {result.get('error', result.get('platform'))}") + + if args.json_path: + args.json_path.parent.mkdir(parents=True, exist_ok=True) + args.json_path.write_text( + json.dumps({"schema": 1, "results": results}, indent=2) + "\n", + encoding="utf-8", + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/package_setup_host.sh b/tools/package_setup_host.sh index 426939d2..2edcd20b 100755 --- a/tools/package_setup_host.sh +++ b/tools/package_setup_host.sh @@ -530,9 +530,13 @@ ${DISPLAY_NAME} ${VERSION} — setup package Platform: ${ARTIFACT} One zip for first install and updates. Does NOT include disc images, retail -BIOS dumps, pre-generated game C, or a portable cmake/clang pack. Emitters +BIOS dumps, pre-generated game C, or a bundled build-tool pack. Emitters (psxrecomp-game / psxrecomp-bios) and the CLI are inside psxrecomp/. +EOF +case "${ARTIFACT}" in + windows-*) + cat >>"${STAGE}/README-SETUP.txt" <>"${STAGE}/README-SETUP.txt" <&2 + exit 1 + ;; +esac # --- Gate: the staged tree must be able to configure itself ---------------- # The project file/dir list is an allowlist, so any path a project adds to diff --git a/tools/toolchain_pack.py b/tools/toolchain_pack.py index c2938cab..1d08d6e1 100644 --- a/tools/toolchain_pack.py +++ b/tools/toolchain_pack.py @@ -956,6 +956,30 @@ def ensure_toolchain( Installs always land under the shared Retro cache; the project only gets a stamp file pointing at that pack. """ + if not sys_platform_is_windows() and from_zip is None: + # Setup/rebuild on Linux and macOS uses native tools. Do not turn a + # missing dependency into an unsolicited portable-pack download. + groups = (("cmake",), ("ninja",), ("python3", "python"), + ("cc", "gcc", "clang"), ("c++", "g++", "clang++")) + resolved = [] + for names in groups: + command = next(filter(None, (shutil.which(name) for name in names)), None) + if command: + try: + result = subprocess.run([command, "--version"], capture_output=True, + text=True, timeout=30, check=False) + if result.returncode == 0: + resolved.append(command) + continue + except (OSError, subprocess.SubprocessError): + pass + raise RuntimeError("Native build tools are missing or unusable: {}. Install CMake, " + "Ninja, Python 3 and C/C++ compilers with your package manager." + .format("/".join(names))) + if log: + log("Using native build tools on PATH") + return Path(resolved[0]).parent + need = min_version or default_min_version() if is_windows_store_python() and log: log(