174 lines
7.3 KiB
Python
174 lines
7.3 KiB
Python
"""Pinned Windows-x64 Vulkan build tools and app-local loader, no GPU execution.
|
|
|
|
SDK copy-only mode does not install drivers or change system environment/registry.
|
|
Checksums are from https://vulkan.lunarg.com/sdk/home/ (2026-10-12).
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import subprocess
|
|
import urllib.request
|
|
import uuid
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
VERSION = "1.4.363.0"
|
|
SDK_SHA = "94a82d378f7a5e3e54c9db7d2fb7016af136e14ac0a18dbf0f2f67a36352d141"
|
|
LOADER_SHA = "a25a927aa8b9f0371048f1861cf88ac3b9bc9b1fb332c42d897c8ab32695769a"
|
|
|
|
|
|
def digest(path):
|
|
with path.open("rb") as stream:
|
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
|
|
|
|
def download(path, remote, expected, limit):
|
|
if path.is_file() and digest(path) == expected:
|
|
return
|
|
temporary = path.with_suffix(path.suffix + ".partial")
|
|
url = f"https://sdk.lunarg.com/sdk/download/{VERSION}/windows/{remote}?Human=true"
|
|
try:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "searchi-build-tooling/1.0"})
|
|
with urllib.request.urlopen(request, timeout=120) as response, temporary.open("wb") as output:
|
|
count = 0
|
|
while block := response.read(1024 * 1024):
|
|
count += len(block)
|
|
if count > limit:
|
|
raise ValueError("Vulkan download exceeds size ceiling")
|
|
output.write(block)
|
|
if digest(temporary) != expected:
|
|
raise ValueError("Vulkan download checksum mismatch")
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def sdk_files(sdk):
|
|
required = [sdk / "Bin/glslc.exe", sdk / "Lib/vulkan-1.lib",
|
|
sdk / "Include/vulkan/vulkan.h", sdk / "Include/vulkan/vulkan_core.h"]
|
|
if not all(path.is_file() for path in required):
|
|
raise RuntimeError("SDK copy did not provide required tools and headers")
|
|
files = set(required)
|
|
for directory in [sdk / "Include/vulkan", sdk / "Include/vk_video"]:
|
|
files.update(path for path in directory.rglob("*") if path.is_file())
|
|
files.update((sdk / "Bin").glob("*.dll"))
|
|
if len(files) > 4096:
|
|
raise ValueError("Vulkan SDK manifest exceeds file count ceiling")
|
|
return {path.relative_to(sdk).as_posix(): digest(path) for path in sorted(files)}
|
|
|
|
|
|
def sdk_verified(sdk):
|
|
manifest = sdk / "searchi-provenance.json"
|
|
try:
|
|
if manifest.stat().st_size > 1024 * 1024:
|
|
return False
|
|
value = json.loads(manifest.read_text(encoding="utf-8"))
|
|
return (value.get("version") == VERSION and value.get("installer_sha256") == SDK_SHA
|
|
and value.get("files") == sdk_files(sdk))
|
|
except (OSError, ValueError, RuntimeError, TypeError, AttributeError):
|
|
return False
|
|
|
|
|
|
def write_sdk_provenance(sdk):
|
|
value = {"version": VERSION, "installer_sha256": SDK_SHA, "files": sdk_files(sdk)}
|
|
temporary = sdk / "searchi-provenance.json.partial"
|
|
try:
|
|
temporary.write_text(json.dumps(value, sort_keys=True), encoding="utf-8")
|
|
os.replace(temporary, sdk / "searchi-provenance.json")
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def owned_path(directory, path):
|
|
"""Resolve and reject escaped/reparse targets before directory moves."""
|
|
owner = directory.resolve()
|
|
target = path.resolve()
|
|
if target == owner or not target.is_relative_to(owner):
|
|
raise ValueError("Vulkan SDK path escapes owned toolchains directory")
|
|
return target
|
|
|
|
|
|
def install_sdk(directory, sdk, installer):
|
|
# Qt's copy-only installer refuses an existing root. Use a fresh sibling,
|
|
# validate it fully, then swap; preserve previous/failed trees for recovery.
|
|
sdk = owned_path(directory, sdk)
|
|
staging = owned_path(directory, directory / f"vulkan-sdk-{VERSION}-staging-{uuid.uuid4().hex}")
|
|
backup = owned_path(directory, directory / f"vulkan-sdk-{VERSION}-backup-{uuid.uuid4().hex}")
|
|
if staging.exists() or backup.exists():
|
|
raise RuntimeError("Generated Vulkan staging or backup directory already exists")
|
|
environment = dict(os.environ, QT_OPENGL="software", SEARCHI_FORCE_CPU="1")
|
|
subprocess.run([str(installer), "--root", str(staging), "--accept-licenses", "--default-answer",
|
|
"--confirm-command", "install", "copy_only=1"], env=environment, check=True,
|
|
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
write_sdk_provenance(staging)
|
|
if not sdk_verified(staging):
|
|
raise RuntimeError("Fresh Vulkan SDK failed provenance validation")
|
|
# Re-resolve immediately before mutation; no recursive removal is performed.
|
|
owned_path(directory, staging)
|
|
owned_path(directory, sdk)
|
|
owned_path(directory, backup)
|
|
previous = sdk.exists()
|
|
if previous:
|
|
os.replace(sdk, backup)
|
|
try:
|
|
os.replace(staging, sdk)
|
|
except OSError:
|
|
if previous and not sdk.exists():
|
|
owned_path(directory, backup)
|
|
owned_path(directory, sdk)
|
|
os.replace(backup, sdk)
|
|
raise
|
|
|
|
|
|
def stage_loader(archive, loader):
|
|
if digest(archive) != LOADER_SHA:
|
|
raise ValueError("Vulkan runtime archive checksum mismatch")
|
|
prefix = f"VulkanRT-X64-{VERSION}-Components/"
|
|
members = []
|
|
with zipfile.ZipFile(archive) as package:
|
|
for source, name in [("x64/vulkan-1.dll", "vulkan-1.dll"),
|
|
("VulkanRT-License.txt", "VULKAN-LICENSE.txt")]:
|
|
member = package.getinfo(prefix + source)
|
|
if member.file_size > 16 * 1024 * 1024:
|
|
raise ValueError("Vulkan runtime member exceeds bound")
|
|
with package.open(member) as stream:
|
|
data = stream.read(16 * 1024 * 1024 + 1)
|
|
if len(data) != member.file_size or len(data) > 16 * 1024 * 1024:
|
|
raise ValueError("Vulkan runtime member exceeds bound or is truncated")
|
|
members.append((name, data))
|
|
loader.mkdir(exist_ok=True)
|
|
for name, data in members:
|
|
destination = loader / name
|
|
if destination.is_file() and digest(destination) == hashlib.sha256(data).hexdigest():
|
|
continue # Never rewrite a verified DLL mapped by another process.
|
|
temporary = destination.with_suffix(destination.suffix + ".partial")
|
|
try:
|
|
temporary.write_bytes(data)
|
|
os.replace(temporary, destination)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def provision():
|
|
if os.name != "nt" or platform.machine().lower() not in {"amd64", "x86_64"}:
|
|
raise RuntimeError("Automatic Vulkan tooling provisioning is verified for Windows x64 only")
|
|
directory = ROOT / ".searchi/toolchains"
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
sdk = directory / f"vulkan-sdk-{VERSION}"
|
|
if not sdk_verified(sdk):
|
|
installer = directory / f"vulkansdk-windows-X64-{VERSION}.exe"
|
|
download(installer, installer.name, SDK_SHA, 400 * 1024 * 1024)
|
|
install_sdk(directory, sdk, installer)
|
|
archive = directory / f"VulkanRT-X64-{VERSION}-Components.zip"
|
|
download(archive, "vulkan-runtime-components.zip", LOADER_SHA, 50 * 1024 * 1024)
|
|
loader = directory / "vulkan-loader"
|
|
stage_loader(archive, loader)
|
|
return sdk, loader
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sdk, loader = provision()
|
|
print(f"Verified SDK: {sdk}\nApp-local loader: {loader}")
|