Restore native POSIX setup tools and platform release validation

This commit is contained in:
Matthew Stanley
2026-10-17 14:17:16 -07:00
parent 444264f2aa
commit 2c92ac3ff1
6 changed files with 393 additions and 5 deletions
+62 -1
View File
@@ -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())
+2
View File
@@ -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):
+110 -3
View File
@@ -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()
@@ -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())
+27 -1
View File
@@ -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" <<EOF
Standalone:
1. Install Python 3.
2. Run ${EXE_BASENAME}.
@@ -546,6 +550,28 @@ Retro uses this same zip: it harvests emitters into a shared SDK cache,
downloads the toolchain pack (or uses RETCOMM_TOOLCHAIN_DIR), and preserves
saves/user config across updates.
EOF
;;
linux-*|macos-*)
cat >>"${STAGE}/README-SETUP.txt" <<EOF
Standalone:
1. Install CMake, Ninja, Python 3, and a C/C++ compiler with your system
package manager.
2. Make sure cmake, ninja, python3, and either clang/clang++ or gcc/g++ are
available on PATH.
3. Run ${EXE_BASENAME}.
4. Provide ${DISC_HINT} and ${BIOS_HINT}.
5. Follow the Generate & rebuild wizard. The host checks the native build
tools before it continues.
Retro uses this same zip. It uses the same native build tools and preserves
saves/user config across updates.
EOF
;;
*)
echo "error: unsupported setup README platform: ${ARTIFACT}" >&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
+24
View File
@@ -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(