diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2b4ac3c..bdc5f11 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -51,11 +51,15 @@ jobs: .github/workflows/validate.yml | linux/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ classic-packages.lock | classic-toolchain.json | \ + classic-shader-toolchain.json | \ + classic-shader-toolchain.spdx.json | \ tools/audio/* | tools/build-sdl3-mixer.sh | \ tools/require-image-checks.sh | \ tools/test-require-image-checks.sh | \ tools/validate-classic-check.sh | \ - tools/validate-classic-image.sh) + tools/validate-classic-image.sh | \ + tools/install_classic_shader_toolchain.py | \ + tools/tests/test_install_classic_shader_toolchain.py) classic=true ;; esac @@ -91,6 +95,9 @@ jobs: - name: Test Classic dependency preflight run: python3 -m unittest tools/tests/test_verify_classic_check_dependencies.py + - name: Test Classic shader toolchain installer + run: python3 -m unittest tools/tests/test_install_classic_shader_toolchain.py + linux: name: Linux image needs: changes diff --git a/AGENTS.md b/AGENTS.md index a8fba01..59876ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,14 @@ changes, the required aggregate validation must still run. - `classic-final` is the slim Classic Check target. Keep its Ubuntu snapshot, direct package lock, tool inventory, non-root ccache mount, Classic validation - revision, smoke/SBOM checks, and published tags synchronized. Do not make it - inherit the broad replacement/development toolchain. + revision, shader-toolchain inventory, GPU runtime, smoke/SBOM checks, and + published tags synchronized. Do not make it inherit the broad + replacement/development toolchain. +- The public Classic image's shader contract is defined by + `classic-shader-toolchain.json`: retain the exact DXC/SPIRV-Cross archive and + source checksums, upstream licenses, and `/usr/local/bin` tool paths. Its + pinned Lavapipe/Xvfb packages provide the equivalent public environment for + fork-safe GPU coverage; consumers still pin a released image digest. - Keep a stable numeric runner UID when restoring a Classic ccache directory; the mode-1777 mount root supports non-root initialization but does not make ccache's owner-writable nested directories reusable across different UIDs. diff --git a/README.md b/README.md index e714119..400e4f0 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ docker run --rm atrinik-linux-build pnpm --version docker run --rm atrinik-classic-build gcc --version docker run --rm atrinik-classic-build cmake --version docker run --rm atrinik-classic-build ccache --version +docker run --rm atrinik-classic-build dxc --version +docker run --rm atrinik-classic-build spirv-cross --help +docker run --rm atrinik-classic-build \ + sh -c 'test -f /usr/share/vulkan/icd.d/lvp_icd.json && \ + VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json vulkaninfo --summary' docker run --rm atrinik-linux-build \ atrinik-sdl3-mixer-probe \ /usr/local/share/atrinik/audio/opus-probe.opus @@ -163,6 +168,17 @@ needed by the Classic client and server. SDL3_mixer and its codec closure retain the checksum-pinned source and nested SPDX inventory used by the development image. +The same public `classic-build` image now carries the qualified DXC release and +SPIRV-Cross source snapshot described by +[`classic-shader-toolchain.json`](classic-shader-toolchain.json). Their +checksum-verified executables are available as `dxc` and `spirv-cross`, with +the upstream license files under `/usr/local/share/licenses`. The direct +Lavapipe, Vulkan, and Xvfb runtime packages are locked in +[`classic-packages.lock`](classic-packages.lock), so a released immutable +Classic digest can serve both the shader-producing build and fork-safe GPU +coverage jobs. The Classic consumer must update its separate workflow to pin +that released digest; this image change does not rewrite consumer source. + Classic runs as the unprivileged `ubuntu` user by default. `/cache/ccache` is a mode-1777 mount contract so CI can run with its own numeric UID and persist the directory without granting root. Consumers must still select ccache explicitly @@ -188,8 +204,9 @@ the digest, never a rolling tag. To update that pin: 1. Update the matching Ubuntu base digest and snapshot value in both `linux/Dockerfile` and `classic-toolchain.json`, refresh the exact direct - versions in `classic-packages.lock`, and update the tool versions and pinned - Classic validation commit in `classic-toolchain.json`. + versions in `classic-packages.lock`, and update the tool versions, shader + coordinates in `classic-shader-toolchain.json`, and pinned Classic + validation commit in `classic-toolchain.json`. 2. Build `classic-validation` and `classic-final`, run the repository checks, and compare compressed image size plus local client/server timings with the prior digest. diff --git a/classic-packages.lock b/classic-packages.lock index b5fcbaa..0d854e3 100644 --- a/classic-packages.lock +++ b/classic-packages.lock @@ -21,11 +21,16 @@ libsdl3-ttf-dev=3.2.2+ds-1build1 libssl-dev=3.5.5-1ubuntu3.3 libssl3t64=3.5.5-1ubuntu3.3 libsubunit-dev=1.4.4+1-1 +libvulkan1=1.4.341.0-1 libxml2-dev=2.15.2+dfsg-0.1ubuntu0.1 +mesa-vulkan-drivers=26.0.3-1ubuntu1 ninja-build=1.13.2-1 openssl=3.5.5-1ubuntu3.3 openssl-provider-legacy=3.5.5-1ubuntu3.3 pkgconf=2.5.1-4 python3=3.14.3-0ubuntu2 python3-dev=3.14.3-0ubuntu2 +vulkan-tools=1.4.341.0+dfsg1-1 +xauth=1:1.1.2-1.1build1 +xvfb=2:21.1.22-1ubuntu1 zlib1g-dev=1:1.3.dfsg+really1.3.1-1ubuntu3 diff --git a/classic-shader-toolchain.json b/classic-shader-toolchain.json new file mode 100644 index 0000000..beb7ffe --- /dev/null +++ b/classic-shader-toolchain.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "schema_version": 1, + "platform": "linux/amd64", + "target": "classic-final", + "image": "ghcr.io/atrinik/classic-build", + "dxc": { + "repository": "microsoft/DirectXShaderCompiler", + "tag": "v1.9.2607", + "commit": "0d3ee6b551b8fa768fbf825300ebab81047ef6a8", + "url": "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/linux_dxc_2026_07_29.x86_x64.tar.gz", + "sha256": "55665c87824051ed4774ff3280a79ccbbb7d39243b9736ca5e98222134112d54", + "archive_root": "linux_dxc_2026_07_29.x86_x64", + "files": { + "bin/dxc": "b1bfa493d5c780b94c20b8b5f5aed50d1c4d03339cd55f496bd223eedeec1734", + "lib/libdxcompiler.so": "be01593d3ff635fca6f20b044aa49c777f6409e50a28fb0242a0c87272620b62", + "lib/libdxil.so": "e74c02162cd553a0ceab041937d749cfd6ab9adf919142603ff5fc55af222c73", + "LICENCE-MIT.txt": "903df5512f7d02609fed0c780a9b704f5a3eeb6e4d84ebe42a29845c81899a3c", + "LICENSE-LLVM.txt": "729615317e28dd03907e46f0fc3b5e88f7853cee61d1a1471d2749335516b46f", + "LICENSE-MS.txt": "734f72f239fe7b07b4c7203f294c1a7ce27095687278bab7e56d630d7c672963" + } + }, + "spirv_cross": { + "repository": "KhronosGroup/SPIRV-Cross", + "commit": "9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + "url": "https://codeload.github.com/KhronosGroup/SPIRV-Cross/tar.gz/9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + "sha256": "78939435d588998e5174a7865ddd36b6d9d7cd05eafac42d42ef537ea770b40a", + "license": "Apache-2.0", + "license_path": "LICENSE", + "license_sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + "install": { + "dxc": { + "executable": "bin/dxc", + "libraries": [ + "lib/libdxcompiler.so", + "lib/libdxil.so" + ], + "licenses": [ + { + "archive_path": "LICENCE-MIT.txt", + "destination": "share/licenses/dxc/LICENCE-MIT.txt" + }, + { + "archive_path": "LICENSE-LLVM.txt", + "destination": "share/licenses/dxc/LICENSE-LLVM.txt" + }, + { + "archive_path": "LICENSE-MS.txt", + "destination": "share/licenses/dxc/LICENSE-MS.txt" + } + ] + }, + "spirv_cross": { + "executable": "bin/spirv-cross", + "license": { + "archive_path": "LICENSE", + "destination": "share/licenses/spirv-cross/LICENSE" + } + } + }, + "runtime": { + "vulkan_icd": "/usr/share/vulkan/icd.d/lvp_icd.json", + "display_server": "Xvfb", + "commands": ["vulkaninfo", "Xvfb", "xvfb-run"] + } +} diff --git a/classic-shader-toolchain.spdx.json b/classic-shader-toolchain.spdx.json new file mode 100644 index 0000000..b13f4b7 --- /dev/null +++ b/classic-shader-toolchain.spdx.json @@ -0,0 +1,57 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "Atrinik Classic GPU shader toolchain", + "documentNamespace": "https://github.com/atrinik/devcontainer/sbom/classic-shader-toolchain/9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + "creationInfo": { + "created": "2026-08-30T00:00:00Z", + "creators": ["Tool: Atrinik devcontainer shader-toolchain inventory"] + }, + "packages": [ + { + "SPDXID": "SPDXRef-Package-DirectXShaderCompiler", + "name": "DirectXShaderCompiler", + "versionInfo": "v1.9.2607", + "downloadLocation": "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/linux_dxc_2026_07_29.x86_x64.tar.gz", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "55665c87824051ed4774ff3280a79ccbbb7d39243b9736ca5e98222134112d54" + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION" + }, + { + "SPDXID": "SPDXRef-Package-SPIRV-Cross", + "name": "SPIRV-Cross", + "versionInfo": "9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + "downloadLocation": "https://codeload.github.com/KhronosGroup/SPIRV-Cross/tar.gz/9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + "filesAnalyzed": false, + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "78939435d588998e5174a7865ddd36b6d9d7cd05eafac42d42ef537ea770b40a" + } + ], + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "copyrightText": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-DirectXShaderCompiler" + }, + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-SPIRV-Cross" + } + ] +} diff --git a/linux/Dockerfile b/linux/Dockerfile index da0af8f..9c3655d 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -405,11 +405,36 @@ RUN test "$(sha256sum /usr/local/share/atrinik/audio/opus-probe.opus \ /usr/local/share/atrinik/audio/opus-probe.opus \ && rm /tmp/sdl3-mixer-probe.c +# Classic's GPU shader pipeline uses a qualified DXC release and a source +# snapshot of SPIRV-Cross. Build the latter from its locked archive and retain +# every upstream license beside the installed tools. The same final image also +# carries the pinned Lavapipe/Xvfb runtime below for fork-safe GPU coverage. +COPY classic-shader-toolchain.json /tmp/classic-shader-toolchain.json +COPY tools/install_classic_shader_toolchain.py \ + /tmp/install_classic_shader_toolchain.py +RUN python3 /tmp/install_classic_shader_toolchain.py \ + --manifest /tmp/classic-shader-toolchain.json \ + --cache /tmp/classic-shader-downloads \ + --prefix /usr/local \ + --jobs "$(nproc)" \ + && ldconfig \ + && test -x /usr/local/bin/dxc \ + && test -x /usr/local/bin/spirv-cross \ + && dxc --version >/dev/null \ + && spirv-cross --help >/dev/null 2>&1 \ + && rm -rf /tmp/classic-shader-downloads \ + /tmp/classic-shader-toolchain.json \ + /tmp/install_classic_shader_toolchain.py + ENV CCACHE_DIR=/cache/ccache RUN install -d -m 1777 "${CCACHE_DIR}" COPY classic-toolchain.json /usr/local/share/atrinik/classic-toolchain.json COPY classic-packages.lock /usr/local/share/atrinik/classic-packages.lock +COPY classic-shader-toolchain.json \ + /usr/local/share/atrinik/classic-shader-toolchain.json +COPY classic-shader-toolchain.spdx.json \ + /usr/local/share/atrinik/classic-shader-toolchain.spdx.json COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json COPY audio-toolchain.spdx.json /usr/local/share/atrinik/audio-toolchain.spdx.json @@ -430,6 +455,8 @@ RUN curl --fail --location --silent --show-error \ COPY classic-packages.lock /workspace/classic-packages.lock COPY classic-toolchain.json /workspace/classic-toolchain.json +COPY classic-shader-toolchain.json /workspace/classic-shader-toolchain.json +COPY classic-shader-toolchain.spdx.json /workspace/classic-shader-toolchain.spdx.json COPY audio-toolchain.json /workspace/audio-toolchain.json COPY linux/Dockerfile /workspace/linux/Dockerfile COPY tools/validate-classic-image.sh /workspace/tools/validate-classic-image.sh @@ -440,7 +467,11 @@ RUN /workspace/tools/validate-classic-image.sh \ /workspace/classic-toolchain.json \ /usr/local/share/atrinik/classic-toolchain.json \ /workspace/audio-toolchain.json \ - /workspace/linux/Dockerfile + /workspace/linux/Dockerfile \ + /workspace/classic-shader-toolchain.json \ + /usr/local/share/atrinik/classic-shader-toolchain.json \ + /workspace/classic-shader-toolchain.spdx.json \ + /usr/local/share/atrinik/classic-shader-toolchain.spdx.json USER root RUN syft dir:/ --select-catalogers +sbom-cataloger -o spdx-json \ diff --git a/tools/install_classic_shader_toolchain.py b/tools/install_classic_shader_toolchain.py new file mode 100644 index 0000000..bac629d --- /dev/null +++ b/tools/install_classic_shader_toolchain.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 +"""Install the checksum-locked Classic Linux GPU shader toolchain.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path, PurePosixPath +import platform +import shutil +import stat +import subprocess +import tarfile +import tempfile +from typing import BinaryIO, Iterable +import urllib.parse +import urllib.request +import json +import os +import re + + +MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_FILE_BYTES = 512 * 1024 * 1024 +MAX_MANIFEST_BYTES = 8 * 1024 * 1024 +MAX_MEMBERS = 100_000 +SHA256_RE = re.compile(r"[0-9a-f]{64}") +COMMIT_RE = re.compile(r"[0-9a-f]{40}") + + +class ToolchainError(RuntimeError): + """The shader toolchain manifest, archive, or build failed validation.""" + + +def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + value: dict[str, object] = {} + for key, item in pairs: + if key in value: + raise ToolchainError(f"duplicate shader toolchain manifest key: {key}") + value[key] = item + return value + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def require_keys(value: dict[str, object], expected: set[str], context: str) -> None: + actual = set(value) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + details = [] + if missing: + details.append(f"missing {', '.join(missing)}") + if extra: + details.append(f"unexpected {', '.join(extra)}") + raise ToolchainError(f"{context}: {'; '.join(details)}") + + +def locked_text(value: object, field: str) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise ToolchainError(f"{field} must be a non-empty trimmed string") + return value + + +def locked_digest(value: object, field: str) -> str: + value = locked_text(value, field) + if not SHA256_RE.fullmatch(value): + raise ToolchainError(f"{field} must be a lowercase SHA-256") + return value + + +def safe_relative_path(value: object, field: str) -> str: + value = locked_text(value, field) + if "\\" in value or "\x00" in value: + raise ToolchainError(f"{field} must be a safe relative path") + path = PurePosixPath(value) + if ( + value != path.as_posix() + or path.is_absolute() + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ToolchainError(f"{field} must be a safe relative path") + return value + + +def safe_absolute_path(value: object, field: str) -> str: + value = locked_text(value, field) + if "\\" in value or "\x00" in value: + raise ToolchainError(f"{field} must be a safe absolute path") + path = PurePosixPath(value) + if ( + value != path.as_posix() + or not path.is_absolute() + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ToolchainError(f"{field} must be a safe absolute path") + return value + + +def canonical_https(value: object, field: str) -> str: + value = locked_text(value, field) + try: + parsed = urllib.parse.urlsplit(value) + except ValueError as error: + raise ToolchainError(f"{field} must be a canonical HTTPS URL") from error + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None + or parsed.port is not None + ): + raise ToolchainError(f"{field} must be a canonical HTTPS URL") + return value + + +def load_manifest(path: Path) -> dict[str, object]: + try: + data = path.read_bytes() + if len(data) > MAX_MANIFEST_BYTES: + raise ToolchainError("shader toolchain manifest exceeds the JSON size limit") + value = json.loads( + data, object_pairs_hook=reject_duplicate_keys + ) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ToolchainError(f"cannot read shader toolchain manifest {path}: {error}") from error + if not isinstance(value, dict): + raise ToolchainError("shader toolchain manifest root must be an object") + require_keys( + value, + { + "$schema", + "schema_version", + "platform", + "target", + "image", + "dxc", + "spirv_cross", + "install", + "runtime", + }, + "manifest root", + ) + if value["$schema"] != "https://json-schema.org/draft/2020-12/schema": + raise ToolchainError("manifest $schema is not the governed schema") + if value["schema_version"] != 1 or value["platform"] != "linux/amd64": + raise ToolchainError("unsupported shader toolchain manifest") + if value["target"] != "classic-final" or value["image"] != "ghcr.io/atrinik/classic-build": + raise ToolchainError("manifest does not describe the public Classic image") + + dxc = value["dxc"] + spirv_cross = value["spirv_cross"] + if not isinstance(dxc, dict) or not isinstance(spirv_cross, dict): + raise ToolchainError("shader tool entries must be objects") + require_keys( + dxc, + {"repository", "tag", "commit", "url", "sha256", "archive_root", "files"}, + "dxc", + ) + require_keys( + spirv_cross, + { + "repository", + "commit", + "url", + "sha256", + "license", + "license_path", + "license_sha256", + }, + "spirv_cross", + ) + if dxc["repository"] != "microsoft/DirectXShaderCompiler": + raise ToolchainError("dxc.repository is not the governed upstream") + if spirv_cross["repository"] != "KhronosGroup/SPIRV-Cross": + raise ToolchainError("spirv_cross.repository is not the governed upstream") + if dxc["tag"] != "v1.9.2607": + raise ToolchainError("dxc.tag is not the qualified release") + if dxc["commit"] != "0d3ee6b551b8fa768fbf825300ebab81047ef6a8": + raise ToolchainError("dxc.commit is not the qualified release commit") + for context, entry in (("dxc", dxc), ("spirv_cross", spirv_cross)): + commit = locked_text(entry["commit"], f"{context}.commit") + if not COMMIT_RE.fullmatch(commit): + raise ToolchainError(f"{context}.commit must be a full lowercase Git SHA") + canonical_https(entry["url"], f"{context}.url") + locked_digest(entry["sha256"], f"{context}.sha256") + + archive_root = safe_relative_path(dxc["archive_root"], "dxc.archive_root") + if "/" in archive_root: + raise ToolchainError("dxc.archive_root must be one safe path component") + expected_dxc_url = ( + "https://github.com/microsoft/DirectXShaderCompiler/releases/download/" + f"{dxc['tag']}/{archive_root}.tar.gz" + ) + if dxc["url"] != expected_dxc_url: + raise ToolchainError("dxc.url does not match its governed release coordinate") + expected_spirv_url = ( + "https://codeload.github.com/KhronosGroup/SPIRV-Cross/tar.gz/" + f"{spirv_cross['commit']}" + ) + if spirv_cross["url"] != expected_spirv_url: + raise ToolchainError("spirv_cross.url does not match its governed commit coordinate") + if spirv_cross["license"] != "Apache-2.0": + raise ToolchainError("spirv_cross.license is not the governed license") + safe_relative_path(spirv_cross["license_path"], "spirv_cross.license_path") + locked_digest(spirv_cross["license_sha256"], "spirv_cross.license_sha256") + + files = dxc["files"] + if not isinstance(files, dict) or set(files) != { + "bin/dxc", + "lib/libdxcompiler.so", + "lib/libdxil.so", + "LICENCE-MIT.txt", + "LICENSE-LLVM.txt", + "LICENSE-MS.txt", + }: + raise ToolchainError("dxc.files does not describe the complete locked archive") + for name, digest in files.items(): + safe_relative_path(name, f"dxc.files.{name}") + locked_digest(digest, f"dxc.files.{name}") + + install = value["install"] + runtime = value["runtime"] + if not isinstance(install, dict) or not isinstance(runtime, dict): + raise ToolchainError("manifest install and runtime entries must be objects") + require_keys(install, {"dxc", "spirv_cross"}, "install") + install_dxc = install["dxc"] + install_spirv = install["spirv_cross"] + if not isinstance(install_dxc, dict) or not isinstance(install_spirv, dict): + raise ToolchainError("manifest install entries must be objects") + require_keys(install_dxc, {"executable", "libraries", "licenses"}, "install.dxc") + require_keys(install_spirv, {"executable", "license"}, "install.spirv_cross") + if install_dxc["executable"] != "bin/dxc": + raise ToolchainError("install.dxc.executable is unexpected") + libraries = install_dxc["libraries"] + if libraries != ["lib/libdxcompiler.so", "lib/libdxil.so"]: + raise ToolchainError("install.dxc.libraries are unexpected") + if not isinstance(libraries, list) or any( + not isinstance(path, str) or path not in files for path in libraries + ): + raise ToolchainError("install.dxc.libraries are not locked DXC members") + licenses = install_dxc["licenses"] + if not isinstance(licenses, list) or len(licenses) != 3: + raise ToolchainError("install.dxc.licenses must contain all DXC licenses") + license_names: set[str] = set() + for index, item in enumerate(licenses): + if not isinstance(item, dict): + raise ToolchainError(f"install.dxc.licenses[{index}] must be an object") + require_keys(item, {"archive_path", "destination"}, f"install.dxc.licenses[{index}]") + archive_path = safe_relative_path( + item["archive_path"], f"install.dxc.licenses[{index}].archive_path" + ) + if archive_path in license_names or archive_path not in files or archive_path.startswith("bin/") or archive_path.startswith("lib/"): + raise ToolchainError("install.dxc.licenses contains an invalid or duplicate member") + license_names.add(archive_path) + safe_relative_path(item["destination"], f"install.dxc.licenses[{index}].destination") + if license_names != {"LICENCE-MIT.txt", "LICENSE-LLVM.txt", "LICENSE-MS.txt"}: + raise ToolchainError("install.dxc.licenses is incomplete") + if install_spirv["executable"] != "bin/spirv-cross": + raise ToolchainError("install.spirv_cross.executable is unexpected") + spirv_license = install_spirv["license"] + if not isinstance(spirv_license, dict): + raise ToolchainError("install.spirv_cross.license must be an object") + require_keys(spirv_license, {"archive_path", "destination"}, "install.spirv_cross.license") + if spirv_license["archive_path"] != spirv_cross["license_path"]: + raise ToolchainError("SPIRV-Cross license path is inconsistent") + safe_relative_path(spirv_license["archive_path"], "install.spirv_cross.license.archive_path") + safe_relative_path(spirv_license["destination"], "install.spirv_cross.license.destination") + + require_keys(runtime, {"vulkan_icd", "display_server", "commands"}, "runtime") + if runtime["vulkan_icd"] != "/usr/share/vulkan/icd.d/lvp_icd.json": + raise ToolchainError("runtime.vulkan_icd is not the governed Lavapipe path") + if runtime["display_server"] != "Xvfb": + raise ToolchainError("runtime.display_server is not Xvfb") + if runtime["commands"] != ["vulkaninfo", "Xvfb", "xvfb-run"]: + raise ToolchainError("runtime.commands are incomplete") + return value + + +def download(url: str, expected: str, cache: Path) -> Path: + cache.mkdir(parents=True, exist_ok=True) + destination = cache / f"{expected}.tar.gz" + if destination.is_symlink(): + raise ToolchainError(f"shader toolchain cache entry is a symlink: {destination}") + if destination.is_file() and destination.stat().st_size <= MAX_ARCHIVE_BYTES: + if sha256(destination) == expected: + return destination + elif destination.exists(): + raise ToolchainError(f"invalid shader toolchain cache entry: {destination}") + + request = urllib.request.Request(url, headers={"User-Agent": "Atrinik shader toolchain/1"}) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{expected}.", dir=cache) + temporary = Path(temporary_name) + try: + total = 0 + with os.fdopen(descriptor, "wb") as output, urllib.request.urlopen( + request, timeout=120 + ) as response: + final = urllib.parse.urlsplit(response.geturl()) + if final.scheme != "https": + raise ToolchainError("shader toolchain download left HTTPS") + while block := response.read(1024 * 1024): + total += len(block) + if total > MAX_ARCHIVE_BYTES: + raise ToolchainError("shader toolchain archive exceeds size limit") + output.write(block) + if sha256(temporary) != expected: + raise ToolchainError("shader toolchain archive digest mismatch") + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + +def safe_archive_path(name: str) -> PurePosixPath: + if not name or "\x00" in name or "\\" in name: + raise ToolchainError(f"unsafe shader archive member: {name}") + path = PurePosixPath(name) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise ToolchainError(f"unsafe shader archive member: {name}") + return path + + +def copy_member(source: BinaryIO, destination: Path, size: int) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as output: + remaining = size + while remaining: + block = source.read(min(1024 * 1024, remaining)) + if not block: + raise ToolchainError(f"truncated archive member: {destination}") + output.write(block) + remaining -= len(block) + + +def extract_dxc(archive_path: Path, destination: Path, entry: dict[str, object]) -> None: + root = str(entry["archive_root"]) + files = entry["files"] + assert isinstance(files, dict) + expected = {f"{root}/{name}": name for name in files} + seen: set[str] = set() + count = 0 + with tarfile.open(archive_path, mode="r:gz") as archive: + for member in archive: + count += 1 + if count > MAX_MEMBERS: + raise ToolchainError("DXC archive has too many members") + safe_archive_path(member.name) + name = expected.get(member.name) + if name is None: + continue + if name in seen or not member.isfile() or member.size > MAX_FILE_BYTES: + raise ToolchainError(f"invalid DXC archive member: {member.name}") + source = archive.extractfile(member) + if source is None: + raise ToolchainError(f"cannot read DXC archive member: {member.name}") + with source: + copy_member(source, destination / name, member.size) + output = destination / name + output.chmod(0o755 if name == "bin/dxc" else 0o644) + if sha256(output) != files[name]: + raise ToolchainError(f"DXC member digest mismatch: {name}") + seen.add(name) + if seen != set(files): + missing = sorted(set(files) - seen) + raise ToolchainError(f"DXC archive is missing locked members: {missing}") + + +def source_relative_path(name: str) -> PurePosixPath | None: + path = safe_archive_path(name) + if len(path.parts) <= 1: + return None + return PurePosixPath(*path.parts[1:]) + + +def extract_spirv_cross(archive_path: Path, destination: Path) -> None: + seen: set[str] = set() + expanded = 0 + count = 0 + with tarfile.open(archive_path, mode="r:gz") as archive: + for member in archive: + count += 1 + if count > MAX_MEMBERS: + raise ToolchainError("SPIRV-Cross archive has too many members") + relative = source_relative_path(member.name) + if relative is None: + continue + key = relative.as_posix().casefold() + if key in seen: + raise ToolchainError(f"duplicate SPIRV-Cross archive path: {relative}") + seen.add(key) + output = destination.joinpath(*relative.parts) + if member.isdir(): + output.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile() or member.size > MAX_FILE_BYTES: + raise ToolchainError(f"unsupported SPIRV-Cross archive member: {member.name}") + expanded += member.size + if expanded > MAX_EXPANDED_BYTES: + raise ToolchainError("SPIRV-Cross archive exceeds expanded size limit") + source = archive.extractfile(member) + if source is None: + raise ToolchainError(f"cannot read SPIRV-Cross archive member: {member.name}") + with source: + copy_member(source, output, member.size) + output.chmod(stat.S_IMODE(member.mode) & 0o755 or 0o644) + if not (destination / "CMakeLists.txt").is_file(): + raise ToolchainError("SPIRV-Cross archive has no source root") + + +def install_file(source: Path, destination: Path, expected: str, executable: bool) -> None: + if source.is_symlink() or not source.is_file(): + raise ToolchainError(f"shader toolchain source is not a regular file: {source}") + if sha256(source) != expected: + raise ToolchainError(f"shader toolchain installed file digest mismatch: {source}") + if destination.is_symlink(): + raise ToolchainError(f"shader toolchain destination is a symlink: {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.tmp") + temporary.unlink(missing_ok=True) + shutil.copyfile(source, temporary) + temporary.chmod(0o755 if executable else 0o644) + temporary.replace(destination) + + +def run_build(source: Path, build: Path, jobs: int) -> Path: + subprocess.run( + [ + "cmake", + "-S", + str(source), + "-B", + str(build), + "-G", + "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + "-DSPIRV_CROSS_CLI=ON", + "-DSPIRV_CROSS_ENABLE_TESTS=OFF", + "-DGIT_EXECUTABLE=GIT_EXECUTABLE-NOTFOUND", + ], + check=True, + ) + subprocess.run( + ["cmake", "--build", str(build), "--target", "spirv-cross", "--parallel", str(jobs)], + check=True, + ) + executable = build / "spirv-cross" + if executable.is_symlink() or not executable.is_file() or not os.access(executable, os.X_OK): + raise ToolchainError("SPIRV-Cross build did not produce an executable") + return executable + + +def install_manifest_files(manifest: dict[str, object], prefix: Path, staging: Path) -> None: + dxc = manifest["dxc"] + spirv_cross = manifest["spirv_cross"] + install = manifest["install"] + assert isinstance(dxc, dict) and isinstance(spirv_cross, dict) and isinstance(install, dict) + install_dxc = install["dxc"] + install_spirv = install["spirv_cross"] + assert isinstance(install_dxc, dict) and isinstance(install_spirv, dict) + files = dxc["files"] + assert isinstance(files, dict) + dxc_source = staging / "dxc" + install_file( + dxc_source / str(install_dxc["executable"]), + prefix / str(install_dxc["executable"]), + str(files[str(install_dxc["executable"])]), + True, + ) + for relative in install_dxc["libraries"]: # type: ignore[union-attr] + install_file( + dxc_source / str(relative), + prefix / str(relative), + str(files[str(relative)]), + False, + ) + for license_entry in install_dxc["licenses"]: # type: ignore[union-attr] + assert isinstance(license_entry, dict) + archive_path = str(license_entry["archive_path"]) + install_file( + dxc_source / archive_path, + prefix / str(license_entry["destination"]), + str(files[archive_path]), + False, + ) + + spirv_license = install_spirv["license"] + assert isinstance(spirv_license, dict) + source_license = staging / "spirv-cross-source" / str(spirv_license["archive_path"]) + install_file( + source_license, + prefix / str(spirv_license["destination"]), + str(spirv_cross["license_sha256"]), + False, + ) + spirv_executable = staging / "spirv-cross-build" / "spirv-cross" + install_file( + spirv_executable, + prefix / str(install_spirv["executable"]), + sha256(spirv_executable), + True, + ) + + +def install(manifest_path: Path, cache: Path, prefix: Path, jobs: int) -> None: + manifest = load_manifest(manifest_path.resolve(strict=True)) + if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}: + raise ToolchainError("the governed shader toolchain targets x86-64 Linux") + if jobs < 1 or jobs > 256: + raise ToolchainError("jobs must be between 1 and 256") + prefix = prefix.resolve() + if prefix == Path("/") or not prefix.is_absolute(): + raise ToolchainError("install prefix must be a non-root absolute path") + dxc = manifest["dxc"] + spirv_cross = manifest["spirv_cross"] + assert isinstance(dxc, dict) and isinstance(spirv_cross, dict) + dxc_archive = download(str(dxc["url"]), str(dxc["sha256"]), cache) + spirv_archive = download(str(spirv_cross["url"]), str(spirv_cross["sha256"]), cache) + staging = Path(tempfile.mkdtemp(prefix=".classic-shader-toolchain-", dir=cache.parent)) + try: + extract_dxc(dxc_archive, staging / "dxc", dxc) + extract_spirv_cross(spirv_archive, staging / "spirv-cross-source") + run_build( + staging / "spirv-cross-source", + staging / "spirv-cross-build", + jobs, + ) + install_manifest_files(manifest, prefix, staging) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--prefix", type=Path, required=True) + parser.add_argument("--jobs", type=int, required=True) + arguments = parser.parse_args(argv) + try: + install(arguments.manifest, arguments.cache.resolve(), arguments.prefix, arguments.jobs) + except (OSError, subprocess.CalledProcessError, ToolchainError) as error: + parser.error(str(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_install_classic_shader_toolchain.py b/tools/tests/test_install_classic_shader_toolchain.py new file mode 100644 index 0000000..5a24ee2 --- /dev/null +++ b/tools/tests/test_install_classic_shader_toolchain.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import hashlib +import io +import importlib.util +from pathlib import Path +import tarfile +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "install_classic_shader_toolchain", + ROOT / "tools" / "install_classic_shader_toolchain.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def add_file(archive: tarfile.TarFile, name: str, content: bytes, mode: int = 0o644) -> None: + info = tarfile.TarInfo(name) + info.size = len(content) + info.mode = mode + archive.addfile(info, io.BytesIO(content)) + + +class ClassicShaderToolchainTests(unittest.TestCase): + def test_loads_the_qualified_manifest(self) -> None: + manifest = MODULE.load_manifest(ROOT / "classic-shader-toolchain.json") + self.assertEqual(manifest["dxc"]["tag"], "v1.9.2607") # type: ignore[index] + self.assertEqual( # type: ignore[index] + manifest["spirv_cross"]["commit"], + "9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382", + ) + + def test_rejects_a_different_qualified_dxc_commit(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "manifest.json" + value = (ROOT / "classic-shader-toolchain.json").read_text(encoding="utf-8") + path.write_text( + value.replace( + "0d3ee6b551b8fa768fbf825300ebab81047ef6a8", + "f" * 40, + 1, + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(MODULE.ToolchainError, "qualified release commit"): + MODULE.load_manifest(path) + + def test_rejects_duplicate_json_keys(self) -> None: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as stream: + stream.write('{"schema_version": 1, "schema_version": 1}\n') + stream.flush() + with self.assertRaisesRegex(MODULE.ToolchainError, "duplicate"): + MODULE.load_manifest(Path(stream.name)) + + def test_rejects_unsafe_archive_paths(self) -> None: + for value in ("/etc/passwd", "root/../outside", "root\\outside", "root/\x00bad"): + with self.subTest(value=value), self.assertRaisesRegex( + MODULE.ToolchainError, "unsafe shader archive member" + ): + MODULE.safe_archive_path(value) + + def test_extracts_only_locked_dxc_files_and_checks_digests(self) -> None: + contents = { + "bin/dxc": b"dxc", + "lib/libdxcompiler.so": b"compiler", + "lib/libdxil.so": b"dxil", + "LICENCE-MIT.txt": b"mit", + } + entry = { + "archive_root": "dxc-root", + "files": {name: digest(value) for name, value in contents.items()}, + } + with tempfile.TemporaryDirectory() as temporary: + archive_path = Path(temporary) / "dxc.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + for name, value in contents.items(): + add_file(archive, f"dxc-root/{name}", value, 0o755 if name == "bin/dxc" else 0o644) + add_file(archive, "dxc-root/unused.txt", b"ignored") + break + for name, value in list(contents.items())[1:]: + add_file(archive, f"dxc-root/{name}", value) + destination = Path(temporary) / "dxc" + MODULE.extract_dxc(archive_path, destination, entry) + self.assertEqual((destination / "bin/dxc").read_bytes(), b"dxc") + self.assertFalse((destination / "unused.txt").exists()) + + def test_rejects_a_symlink_in_a_locked_dxc_member(self) -> None: + entry = {"archive_root": "dxc-root", "files": {"bin/dxc": "0" * 64}} + with tempfile.TemporaryDirectory() as temporary: + archive_path = Path(temporary) / "dxc.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + info = tarfile.TarInfo("dxc-root/bin/dxc") + info.type = tarfile.SYMTYPE + info.linkname = "outside" + archive.addfile(info) + with self.assertRaisesRegex(MODULE.ToolchainError, "invalid DXC archive member"): + MODULE.extract_dxc(archive_path, Path(temporary) / "dxc", entry) + + def test_extracts_spirv_cross_without_path_traversal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + archive_path = Path(temporary) / "spirv-cross.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + directory = tarfile.TarInfo("SPIRV-Cross-root") + directory.type = tarfile.DIRTYPE + archive.addfile(directory) + add_file(archive, "SPIRV-Cross-root/CMakeLists.txt", b"project(test)\n") + add_file(archive, "SPIRV-Cross-root/LICENSE", b"Apache License\n") + destination = Path(temporary) / "source" + MODULE.extract_spirv_cross(archive_path, destination) + self.assertTrue((destination / "CMakeLists.txt").is_file()) + self.assertEqual((destination / "LICENSE").read_text(), "Apache License\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh index 261a0cd..55782c6 100755 --- a/tools/validate-classic-image.sh +++ b/tools/validate-classic-image.sh @@ -2,8 +2,8 @@ set -euo pipefail -if [[ $# -ne 5 ]]; then - echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY DOCKERFILE" >&2 +if [[ $# -ne 9 ]]; then + echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY DOCKERFILE SHADER_INVENTORY SHADER_INSTALLED SHADER_SPDX SHADER_SPDX_INSTALLED" >&2 exit 2 fi @@ -12,8 +12,13 @@ expected=$2 installed=$3 audio_inventory=$4 dockerfile=$5 +shader_expected=$6 +shader_installed=$7 +shader_spdx_expected=$8 +shader_spdx_installed=$9 cmp --silent "${expected}" "${installed}" +cmp --silent "${shader_expected}" "${shader_installed}" jq -e ' .schema_version == 1 @@ -29,6 +34,128 @@ jq -e ' and (.consumer_validation.commit | test("^[0-9a-f]{40}$")) ' "${expected}" >/dev/null +jq -e ' + .schema_version == 1 + and .platform == "linux/amd64" + and .target == "classic-final" + and .image == "ghcr.io/atrinik/classic-build" + and .dxc.repository == "microsoft/DirectXShaderCompiler" + and .dxc.tag == "v1.9.2607" + and .dxc.commit == "0d3ee6b551b8fa768fbf825300ebab81047ef6a8" + and .dxc.url == "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/linux_dxc_2026_07_29.x86_x64.tar.gz" + and .dxc.sha256 == "55665c87824051ed4774ff3280a79ccbbb7d39243b9736ca5e98222134112d54" + and .dxc.archive_root == "linux_dxc_2026_07_29.x86_x64" + and ([.dxc.files | to_entries[] | .key] | sort) == [ + "LICENCE-MIT.txt", "LICENSE-LLVM.txt", "LICENSE-MS.txt", + "bin/dxc", "lib/libdxcompiler.so", "lib/libdxil.so" + ] + and .spirv_cross.repository == "KhronosGroup/SPIRV-Cross" + and .spirv_cross.commit == "9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382" + and .spirv_cross.url == "https://codeload.github.com/KhronosGroup/SPIRV-Cross/tar.gz/9c3c8e2cefdd8194b193bb8ed2fdff4d5527e382" + and .spirv_cross.sha256 == "78939435d588998e5174a7865ddd36b6d9d7cd05eafac42d42ef537ea770b40a" + and .spirv_cross.license == "Apache-2.0" + and .spirv_cross.license_path == "LICENSE" + and .spirv_cross.license_sha256 == "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + and .install.dxc.executable == "bin/dxc" + and .install.dxc.libraries == ["lib/libdxcompiler.so", "lib/libdxil.so"] + and .install.dxc.licenses == [ + {"archive_path":"LICENCE-MIT.txt", "destination":"share/licenses/dxc/LICENCE-MIT.txt"}, + {"archive_path":"LICENSE-LLVM.txt", "destination":"share/licenses/dxc/LICENSE-LLVM.txt"}, + {"archive_path":"LICENSE-MS.txt", "destination":"share/licenses/dxc/LICENSE-MS.txt"} + ] + and .install.spirv_cross == { + "executable":"bin/spirv-cross", + "license": { + "archive_path":"LICENSE", + "destination":"share/licenses/spirv-cross/LICENSE" + } + } + and .runtime == { + "vulkan_icd":"/usr/share/vulkan/icd.d/lvp_icd.json", + "display_server":"Xvfb", + "commands":["vulkaninfo", "Xvfb", "xvfb-run"] + } +' "${shader_expected}" >/dev/null + +jq -e --slurpfile inventory "${shader_expected}" ' + def manifest: $inventory[0]; + .spdxVersion == "SPDX-2.3" + and .dataLicense == "CC0-1.0" + and .SPDXID == "SPDXRef-DOCUMENT" + and ([.packages[].name] | sort) == ["DirectXShaderCompiler", "SPIRV-Cross"] + and ([.packages[].SPDXID] | unique | length) == 2 + and all(.packages[]; + .filesAnalyzed == false + and (.checksums | length) == 1 + and .checksums[0].algorithm == "SHA256" + and ((.name == "DirectXShaderCompiler" + and .versionInfo == manifest.dxc.tag + and .downloadLocation == manifest.dxc.url + and .checksums[0].checksumValue == manifest.dxc.sha256 + and .licenseDeclared == "NOASSERTION") + or + (.name == "SPIRV-Cross" + and .versionInfo == manifest.spirv_cross.commit + and .downloadLocation == manifest.spirv_cross.url + and .checksums[0].checksumValue == manifest.spirv_cross.sha256 + and .licenseDeclared == manifest.spirv_cross.license)) + ) + and .relationships == [ + { + "spdxElementId":"SPDXRef-DOCUMENT", + "relationshipType":"DESCRIBES", + "relatedSpdxElement":"SPDXRef-Package-DirectXShaderCompiler" + }, + { + "spdxElementId":"SPDXRef-DOCUMENT", + "relationshipType":"DESCRIBES", + "relatedSpdxElement":"SPDXRef-Package-SPIRV-Cross" + } + ] +' "${shader_spdx_expected}" >/dev/null +cmp --silent "${shader_spdx_expected}" "${shader_spdx_installed}" + +while IFS=$'\t' read -r relative digest; do + case "${relative}" in + bin/dxc|lib/libdxcompiler.so|lib/libdxil.so|LICENCE-MIT.txt|LICENSE-LLVM.txt|LICENSE-MS.txt) ;; + *) echo "unexpected locked DXC member: ${relative}" >&2; exit 1 ;; + esac + installed_path="/usr/local/${relative}" + test -f "${installed_path}" + test "$(sha256sum "${installed_path}" | cut -d' ' -f1)" = "${digest}" +done < <(jq -r '.dxc.files | to_entries[] + | select(.key == "bin/dxc" or (.key | startswith("lib/"))) + | [.key, .value] | @tsv' "${shader_expected}") + +test -x /usr/local/bin/dxc +test -x /usr/local/bin/spirv-cross +dxc --version >/dev/null 2>&1 +spirv-cross --help >/dev/null 2>&1 + +while IFS=$'\t' read -r command; do + command -v "${command}" >/dev/null +done < <(jq -r '.runtime.commands[]' "${shader_expected}") +vulkan_icd=$(jq -er '.runtime.vulkan_icd' "${shader_expected}") +test -f "${vulkan_icd}" +VK_DRIVER_FILES="${vulkan_icd}" vulkaninfo --summary >/dev/null + +while IFS=$'\t' read -r relative destination; do + case "${relative}:${destination}" in + LICENCE-MIT.txt:share/licenses/dxc/LICENCE-MIT.txt|\ + LICENSE-LLVM.txt:share/licenses/dxc/LICENSE-LLVM.txt|\ + LICENSE-MS.txt:share/licenses/dxc/LICENSE-MS.txt) ;; + *) echo "unexpected DXC license destination: ${relative}:${destination}" >&2; exit 1 ;; + esac + test -f "/usr/local/${destination}" + test "$(sha256sum "/usr/local/${destination}" | cut -d' ' -f1)" = \ + "$(jq -er --arg name "${relative}" '.dxc.files[$name]' "${shader_expected}")" +done < <(jq -r '.install.dxc.licenses[] | [.archive_path, .destination] | @tsv' "${shader_expected}") + +spirv_license_destination=$(jq -er '.install.spirv_cross.license.destination' "${shader_expected}") +test -f "/usr/local/${spirv_license_destination}" +test "$(sha256sum "/usr/local/${spirv_license_destination}" | cut -d' ' -f1)" = \ + "$(jq -er '.spirv_cross.license_sha256' "${shader_expected}")" + test "linux/$(dpkg --print-architecture)" = "$(jq -r '.platform' "${expected}")" expected_image=$(jq -r '.base.image' "${expected}") expected_digest=$(jq -r '.base.digest' "${expected}")