diff --git a/build.py b/build.py index d87c5e4c1..773f70c2e 100644 --- a/build.py +++ b/build.py @@ -697,6 +697,30 @@ def _cmake_cache_value(project: str, key: str) -> str: return values[0].strip() +def _msvc_crt_dir(vc_root: Path, redist_version: str) -> Path: + x64_dir = vc_root / "Redist" / "MSVC" / redist_version / "x64" + try: + entries = tuple(x64_dir.iterdir()) + except OSError as error: + raise SystemExit(f"cannot inspect matching x64 MSVC redist {x64_dir}: {error}") from error + + candidates: list[Path] = [] + for entry in entries: + if re.fullmatch(r"Microsoft\.VC[0-9]+\.CRT", entry.name, re.IGNORECASE) is None: + continue + if entry.is_symlink() or (hasattr(entry, "is_junction") and entry.is_junction()): + continue + if entry.is_dir(): + candidates.append(entry) + if len(candidates) != 1: + names = sorted(entry.name for entry in candidates) + raise SystemExit( + "matching x64 MSVC redist must contain exactly one CRT family; " + f"found {names} under {x64_dir}" + ) + return candidates[0] + + def _msvc_runtime_sources( project: str, runtime_names: tuple[str, ...] ) -> tuple[Path, tuple[_AppLocalRuntimeFile, ...]]: @@ -734,18 +758,14 @@ def _msvc_runtime_sources( if re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}", redist_version) is None: raise SystemExit(f"invalid MSVC redist version in {version_file}: {redist_version!r}") - crt_dir = ( - vc_root - / "Redist" - / "MSVC" - / redist_version - / "x64" - / "Microsoft.VC143.CRT" - ) + crt_dir = _msvc_crt_dir(vc_root, redist_version) try: + resolved_x64_dir = crt_dir.parent.resolve(strict=True) resolved_crt_dir = crt_dir.resolve(strict=True) except OSError as error: - raise SystemExit(f"matching x64 VC143 redist directory missing: {crt_dir}") from error + raise SystemExit(f"cannot resolve x64 MSVC redist directory: {crt_dir}") from error + if resolved_crt_dir.parent != resolved_x64_dir: + raise SystemExit(f"x64 MSVC runtime escapes its matching Redist directory: {crt_dir}") files: list[_AppLocalRuntimeFile] = [] for name in runtime_names: diff --git a/scripts/test_build_runtime.py b/scripts/test_build_runtime.py index 62f396788..d5223d3b0 100644 --- a/scripts/test_build_runtime.py +++ b/scripts/test_build_runtime.py @@ -43,7 +43,13 @@ def _snapshot(directory: Path) -> dict[str, bytes]: class _RuntimeFixture: - def __init__(self) -> None: + def __init__( + self, + *, + toolset_version: str = "14.44.35207", + redist_version: str = "14.44.35112", + crt_family: str = "143", + ) -> None: self.temp = tempfile.TemporaryDirectory() self.base = Path(self.temp.name) self.root = self.base / "repo" @@ -57,7 +63,7 @@ def __init__(self) -> None: self.vc / "Tools" / "MSVC" - / "14.44.35207" + / toolset_version / "bin" / "Hostx64" / "x64" @@ -69,17 +75,8 @@ def __init__(self) -> None: self.vc / "Auxiliary" / "Build" / "Microsoft.VCRedistVersion.default.txt" ) version_file.parent.mkdir(parents=True) - version_file.write_text("14.44.35112\n", encoding="utf-8") - self.crt = ( - self.vc - / "Redist" - / "MSVC" - / "14.44.35112" - / "x64" - / "Microsoft.VC143.CRT" - ) - for index, name in enumerate(_RUNTIME_NAMES): - _write_pe(self.crt / name, marker=f"source-{index}".encode()) + version_file.write_text(f"{redist_version}\n", encoding="utf-8") + self.crt = self.add_crt_family(crt_family, redist_version) cache = self.app / "build" / "windows" / "x64" / "CMakeCache.txt" cache.parent.mkdir(parents=True, exist_ok=True) @@ -90,6 +87,19 @@ def __init__(self) -> None: ) self.extra_app_imports: tuple[str, ...] = () + def add_crt_family(self, family: str, version: str = "14.44.35112") -> Path: + crt = ( + self.vc + / "Redist" + / "MSVC" + / version + / "x64" + / f"Microsoft.VC{family}.CRT" + ) + for index, name in enumerate(_RUNTIME_NAMES): + _write_pe(crt / name, marker=f"{version}-source-{index}".encode()) + return crt + def close(self) -> None: self.temp.cleanup() @@ -156,6 +166,50 @@ def record_sign(paths: list[Path], dry: bool) -> None: for name in _RUNTIME_NAMES: self.assertEqual((fixture.bundle / name).read_bytes(), (fixture.crt / name).read_bytes()) + def test_stages_vs2026_vc145_runtime(self) -> None: + fixture = _RuntimeFixture( + toolset_version="14.51.36231", + redist_version="14.51.36231", + crt_family="145", + ) + self.addCleanup(fixture.close) + + with fixture.patched(), mock.patch.object(gore_build, "sign_paths"): + plan = gore_build._sign_and_stage_app_local_runtime( + "gore-mod-manager", fixture.bundle, dry=False + ) + + self.assertIsNotNone(plan) + for name in _RUNTIME_NAMES: + self.assertEqual((fixture.bundle / name).read_bytes(), (fixture.crt / name).read_bytes()) + + def test_rejects_missing_or_ambiguous_crt_family_before_signing(self) -> None: + def missing(fixture: _RuntimeFixture) -> None: + shutil.rmtree(fixture.crt) + + def ambiguous(fixture: _RuntimeFixture) -> None: + fixture.add_crt_family("145") + + for label, mutate in (("missing", missing), ("ambiguous", ambiguous)): + with self.subTest(label=label): + fixture = _RuntimeFixture() + try: + before = _snapshot(fixture.bundle) + mutate(fixture) + with fixture.patched(), mock.patch.object( + gore_build, "sign_paths" + ) as signer: + with self.assertRaisesRegex( + SystemExit, "exactly one CRT family" + ): + gore_build._sign_and_stage_app_local_runtime( + "gore-mod-manager", fixture.bundle, dry=False + ) + signer.assert_not_called() + self.assertEqual(_snapshot(fixture.bundle), before) + finally: + fixture.close() + def test_invalid_sources_and_closure_leave_bundle_unchanged(self) -> None: def missing_source(fixture: _RuntimeFixture) -> None: (fixture.crt / "vcruntime140_1.dll").unlink()