diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ef6c777 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +/.github/CODEOWNERS @leehack +/tools/build.py @leehack +/tools/validate_artifacts.py @leehack +/tools/package_release.py @leehack +/tools/prepared_release.py @leehack +/tools/vulkan_tri.py @leehack +/tools/runtime_archive.py @leehack +/tests/test_vulkan_tri*.py @leehack +/tests/fixtures/vulkan/ @leehack +/docs/vulkan_tri_guard.md @leehack diff --git a/README.md b/README.md index d39c5bc..63aa2b9 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Vulkan variants link the Vulkan loader (`libvulkan.so.1`, `vulkan-1.dll`) and fail to load without it; consumers fall back to the CPU variant. CI checks them on Mesa lavapipe only, so GPU performance still needs hardware runs. +Affected GLSL compilers can compile `tri_f16`'s integer zero constructor to +NaN. Vulkan builds, packaging and prepared-artifact replay fail unless the +actual embedded triangle module has a recognized layout and its rejected +branch stores positive zero. This validates the exact artifact; the upstream +source and compiler remain unchanged. See [the shader guard](docs/vulkan_tri_guard.md). + Android GPU backends are not shipped. On tested devices, Vulkan crashed in the Adreno 750 driver's shader compiler and on a null `vkGetBufferDeviceAddress` on Mali-G68, and OpenCL on Adreno 750 was slower than the CPU. diff --git a/docs/vulkan_tri_guard.md b/docs/vulkan_tri_guard.md new file mode 100644 index 0000000..35e50e8 --- /dev/null +++ b/docs/vulkan_tri_guard.md @@ -0,0 +1,44 @@ +# Vulkan triangle artifact guard + +Some GLSL compilers turn GGML's `tri.comp` `D_TYPE(0)` into a NaN in the +`tri_f16` module. Two builds of identical sources can differ. The owner source +and upstream submodule remain unchanged; compilation is allowed to finish, +then the exact generated and embedded bytes must pass the guard before release. +A rejected build is not qualified by a passing retry or by another target. + +`tools/vulkan_tri.py` admits the reviewed 6352-byte and 6388-byte module +layouts. Identification normalizes only a float32 constant narrowed to half; +validation examines the original bytes. It requires one triangle mode switch, +the accepted branch's input-to-output copy, and the rejected branch's float32 +positive zero converted to half and stored to output binding 1. NaN, finite +nonzero, negative zero, missing or ambiguous modules, changed layouts and a +missing output data path fail closed. This is a narrow proof for this operation, +not a general shader validator or GPU execution test. + +New builds capture the actual generated `tri_f16.spv` in `build-info.json` as +`vulkanTriF16` schema 1, with its SHA-256 and hex bytes. Build, runtime validation +and packaging require this capture to match the unique module in the runtime. +Package verification and publication inspect the actual library inside every +Vulkan runtime archive again, independently of accelerator flags supplied by +that archive. Existing source, manifest, archive digest and target-inventory +checks remain in force. Runtime archives reject noncanonical names, normalized +or case-fold aliases, links and nonregular members before any byte selection. +Canonical directory entries remain supported, so ordinary extraction cannot replace a validated library through another name. + +Prepared artifacts from before this field existed use an explicit legacy path: +the verifier identifies and proves the same unique known module directly in +the archive's runtime bytes. A present null, malformed or unsupported-schema +field is rejected. The legacy path supplies no invented build capture or owner +commit; its trusted prepared-run source and archive hashes remain unchanged. +It has the same module identity and positive-zero proof as new artifacts. + +A compiler or upstream update that changes the layout needs a fresh independent +source/module review and negative controls before adding an identity. Do not +refresh fingerprints merely to make a failed candidate pass. A source/compiler +workaround can be carried only under `patches/README.md`, including its required +upstream retirement link; this guard does not claim such a fix has landed. + +`tests/test_vulkan_tri.py` covers module, flow, layout and metadata failures. +`tests/test_vulkan_tri_packaging.py` exercises the real build, artifact validator, +package writer and prepared replay entry points using deterministic compiled +fixtures. Heavy device and model qualification remains separate. diff --git a/tests/fixtures/vulkan/README.md b/tests/fixtures/vulkan/README.md new file mode 100644 index 0000000..b4ea0b2 --- /dev/null +++ b/tests/fixtures/vulkan/README.md @@ -0,0 +1,11 @@ +`tri_f16.spv` is the 6352-byte compiled positive-zero module from GGML +`a5915eb0e47adef21c3f25b889a008925ea838c2`, with `tri.comp`'s `D_TYPE(0)` +replaced by `D_TYPE(0.0)`. SHA-256: +`a8691344947943aa581022ecfc95953c02ea3247f85abd0d2f46a23ad49cbe0f`. + +Reproduce with shaderc/glslc using `-fshader-stage=compute +--target-env=vulkan1.2 -O -DA_TYPE=float16_t -DD_TYPE=float16_t`, including +GGML's `vulkan-shaders` directory. The regression suite compiles the actual +pinned source when glslc is available. Deterministic mutations replace its +rejected-branch float32 zero constant with NaN/nonzero or remove its use; +they exercise the validator on every platform without a GPU or SDK. diff --git a/tests/fixtures/vulkan/tri_f16.spv b/tests/fixtures/vulkan/tri_f16.spv new file mode 100644 index 0000000..0a6cb8a Binary files /dev/null and b/tests/fixtures/vulkan/tri_f16.spv differ diff --git a/tests/test_prepared_release.py b/tests/test_prepared_release.py index c138fe8..76a3466 100644 --- a/tests/test_prepared_release.py +++ b/tests/test_prepared_release.py @@ -3,6 +3,7 @@ import argparse import copy import io +import hashlib import json import os import stat @@ -37,7 +38,17 @@ def setUp(self): name = f"{release.PACKAGE}-{kind}-{target}-{TAG}.tar.gz" path = self.dist / name with tarfile.open(path, "w:gz") as archive: - info = json.dumps({"target": target, "upstream": self.manifest["upstream"]}).encode() + build_info = {"target": target, "upstream": self.manifest["upstream"]} + if kind == 'runtime' and 'vulkan' in release.TARGETS[target].accelerators: + module = Path(__file__).parent / 'fixtures/vulkan/tri_f16.spv' + payload = module.read_bytes() + build_info['vulkanTriF16'] = {'schemaVersion': 1, + 'sha256': hashlib.sha256(payload).hexdigest(), + 'spirv_hex': payload.hex()} + library = tarfile.TarInfo('lib/' + release.TARGETS[target].library) + library.size = len(payload) + archive.addfile(library, io.BytesIO(payload)) + info = json.dumps(build_info).encode() entry = tarfile.TarInfo("build-info.json") entry.size = len(info) archive.addfile(entry, io.BytesIO(info)) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index b5955ed..faea848 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -69,6 +69,15 @@ def test_linux_runtime_builds_keep_the_qualified_ubuntu_baseline(self): rf"target: {target}, runs_on: ubuntu-24\.04,", ) + def test_release_byte_guard_sources_have_owner_coverage(self): + owners = (ROOT / '.github/CODEOWNERS').read_text().splitlines() + for path in ('tools/build.py', 'tools/validate_artifacts.py', + 'tools/package_release.py', 'tools/prepared_release.py', + 'tools/vulkan_tri.py', 'tools/runtime_archive.py'): + with self.subTest(path=path): + self.assertTrue((ROOT / path).is_file()) + self.assertIn('/' + path + ' @leehack', owners) + def test_publication_requires_explicit_opt_in(self): workflow = (ROOT / ".github/workflows/native_release.yml").read_text() self.assertIn( diff --git a/tests/test_vulkan_tri.py b/tests/test_vulkan_tri.py new file mode 100644 index 0000000..c14097f --- /dev/null +++ b/tests/test_vulkan_tri.py @@ -0,0 +1,112 @@ +"""Compiled triangle zero-store, source staging and packaged-byte regressions.""" +from __future__ import annotations + +from pathlib import Path +import shutil +import struct +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'tools')) +from vulkan_tri import GENERATED_MODULE, build_record, validate_artifact, validate_module, validate_artifact_bytes + +FIXTURE = ROOT / 'tests/fixtures/vulkan/tri_f16.spv' +GGML = ROOT / 'third_party/stable-diffusion.cpp/ggml' +TRI = 'src/ggml-vulkan/vulkan-shaders/tri.comp' + + +def mutate_constant(value: int) -> bytes: + data = bytearray(FIXTURE.read_bytes()) + at = 20 + while at < len(data): + word = struct.unpack_from('> 16) * 4 + raise AssertionError('zero constant not found') + + +class VulkanTriangleTest(unittest.TestCase): + def test_positive_zero_fixture(self): + validate_module(FIXTURE.read_bytes()) + + def test_nan_and_finite_nonzero_and_negative_zero_are_rejected(self): + for value in [0xfffffe00, 0x3f800000, 0x80000000]: + with self.subTest(value=hex(value)), self.assertRaisesRegex(ValueError, 'positive zero'): + validate_module(mutate_constant(value)) + + def test_missing_conversion_use_is_rejected(self): + data = bytearray(FIXTURE.read_bytes()) + at = 20 + while at < len(data): + word = struct.unpack_from('> 16) * 4 + with self.assertRaisesRegex(ValueError, 'output branches'): + validate_module(bytes(data)) + + def test_malformed_or_truncated_module_is_rejected(self): + for data in [b'', FIXTURE.read_bytes()[:-4], b'bad!' + FIXTURE.read_bytes()[4:]]: + with self.subTest(size=len(data)), self.assertRaises(ValueError): + validate_module(data) + + def test_compiled_module_must_be_unique_in_packaged_library(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / GENERATED_MODULE).parent.mkdir(parents=True) + shutil.copy2(FIXTURE, root / GENERATED_MODULE) + record = build_record(root) + library = root / 'runtime' + library.write_bytes(b'prefix' + FIXTURE.read_bytes() + b'suffix') + validate_artifact(library, record) + for data in [b'none', FIXTURE.read_bytes() * 2, mutate_constant(0xfffffe00)]: + library.write_bytes(data) + with self.assertRaises(ValueError): + validate_artifact(library, record) + library.write_bytes(FIXTURE.read_bytes()) + record['sha256'] = '0' * 64 + with self.assertRaisesRegex(ValueError, 'digest'): + validate_artifact(library, record) + + def test_only_configured_generated_module_is_accepted(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + shutil.copy2(FIXTURE, root / 'tri_f16.spv') + with self.assertRaisesRegex(ValueError, 'generated module is missing'): + build_record(root) + (root / GENERATED_MODULE).parent.mkdir(parents=True) + shutil.copy2(FIXTURE, root / GENERATED_MODULE) + build_record(root) + + def test_present_bad_metadata_is_not_legacy(self): + data = FIXTURE.read_bytes() + validate_artifact_bytes(data, None, allow_legacy=True) + for record in [{}, {'schemaVersion': 2}, {'schemaVersion': 1, 'sha256': '0' * 64, 'spirv_hex': data.hex()}]: + with self.subTest(record=record), self.assertRaises(ValueError): + validate_artifact_bytes(data, record, allow_legacy=True) + + def test_legacy_still_rejects_bad_or_ambiguous_actual_module(self): + for data in [b'none', mutate_constant(0xfffffe00), mutate_constant(0x3f800000), + FIXTURE.read_bytes() + mutate_constant(0xfffffe00)]: + with self.subTest(size=len(data)), self.assertRaises(ValueError): + validate_artifact_bytes(data, None, allow_legacy=True) + + def test_changed_layout_requires_review(self): + data = bytearray(FIXTURE.read_bytes()) + # Generator ID does not change the flow proof; layout admission remains explicit. + struct.pack_into(' Path: for header in HEADERS: shutil.copy2(header, out_dir / "include" / header.name) + vulkan_tri = None + if 'vulkan' in target.accelerators: + from vulkan_tri import build_record, validate_artifact + vulkan_tri = build_record(work_dir) + validate_artifact(out_dir / 'lib' / target.library, vulkan_tri) + info = { "target": target.name, "os": target.os, @@ -270,6 +276,8 @@ def build(target: Target, jobs: int) -> Path: "cmakeArgs": [a for a in cmake_args if not a.startswith("-DCMAKE_SHARED_LINKER_FLAGS")], "exportedSymbols": api_symbols(*HEADERS), } + if vulkan_tri is not None: + info['vulkanTriF16'] = vulkan_tri (out_dir / "build-info.json").write_text(json.dumps(info, indent=2) + "\n") print(f"built {target.name}: {out_dir / 'lib' / target.library}") return out_dir diff --git a/tools/package_release.py b/tools/package_release.py index b261a0c..6d74cc4 100644 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -48,6 +48,10 @@ def _reset(info: tarfile.TarInfo) -> tarfile.TarInfo: def write_archive(dest: Path, entries: dict[str, Path]) -> None: + from runtime_archive import validate_names + validate_names(entries) + if any(not path.is_file() or path.is_symlink() for path in entries.values()): + raise ValueError('archive source contains a linked or nonregular file') with tarfile.open(dest, "w:gz") as archive: for name in sorted(entries): archive.add(entries[name], arcname=name, filter=_reset) @@ -73,6 +77,10 @@ def main() -> None: root = BIN_ROOT / name if not root.is_dir(): continue + info = json.loads((root / "build-info.json").read_text()) + if 'vulkan' in target.accelerators: + from vulkan_tri import validate_artifact + validate_artifact(root / 'lib' / target.library, info.get('vulkanTriF16')) runtime = args.dist / f"{PACKAGE}-runtime-{name}-{args.tag}.tar.gz" entries = { f"lib/{p.name}": p for p in sorted((root / "lib").iterdir()) @@ -86,7 +94,6 @@ def main() -> None: symbols = args.dist / f"{PACKAGE}-symbols-{name}-{args.tag}.tar.gz" write_archive(symbols, {f"symbols/{target.library}": root / "symbols" / target.library}) - info = json.loads((root / "build-info.json").read_text()) for kind, path in (("runtime", runtime), ("symbols", symbols)): artifacts.append({ "target": name, diff --git a/tools/prepared_release.py b/tools/prepared_release.py index 0a5a897..469b3d7 100644 --- a/tools/prepared_release.py +++ b/tools/prepared_release.py @@ -144,11 +144,27 @@ def verify_dist(dist: Path, tag: str, source: str, digest: str, upstream: str) - sums[path.name] = artifact["sha256"] if key[1] == "runtime": with tarfile.open(path, "r:gz") as archive: - entries = [e for e in archive.getmembers() if e.name == "build-info.json"] + from runtime_archive import runtime_members + members = runtime_members(archive) + entries = [e for name, e in members.items() if name == "build-info.json"] require(len(entries) == 1 and entries[0].isfile(), "missing build information") info = json.load(archive.extractfile(entries[0])) require(info["target"] == key[0] and info["upstream"] == manifest["upstream"], "archive build source mismatch") + target = TARGETS[key[0]] + if 'vulkan' in target.accelerators: + from vulkan_tri import validate_artifact_bytes + libraries = [e for name, e in members.items() + if name == f'lib/{target.library}'] + require(len(libraries) == 1 and libraries[0].isfile(), + 'missing or ambiguous Vulkan runtime library') + # Legacy build-info lacks the auxiliary captured-module + # field. Its actual bytes pass the identical layout/flow + # proof; a present but malformed field is never legacy. + allow_legacy = 'vulkanTriF16' not in info + validate_artifact_bytes(archive.extractfile(libraries[0]).read(), + info.get('vulkanTriF16'), + allow_legacy=allow_legacy) require(seen == set(expected), "manifest is missing release targets") require({p.name for p in files} == set(sums) | {"manifest.json", "SHA256SUMS"}, "distribution has extra or missing files") diff --git a/tools/runtime_archive.py b/tools/runtime_archive.py new file mode 100644 index 0000000..99ff105 --- /dev/null +++ b/tools/runtime_archive.py @@ -0,0 +1,31 @@ +"""Portable, unambiguous runtime archive paths before inspecting their bytes.""" +from pathlib import PurePosixPath +import tarfile + + +def validate_names(names) -> None: + seen = set() + for name in names: + path = PurePosixPath(name) + if (not path.parts or any(ord(char) < 32 or ord(char) == 127 for char in name) + or path.is_absolute() or path.as_posix() != name + or any(part in ('.', '..') or '\\' in part or ':' in part + or part.endswith((' ', '.')) for part in path.parts)): + raise ValueError('runtime archive contains a noncanonical path') + # Runtime archives are consumed on case-insensitive Windows/Apple hosts. + key = name.casefold() + if key in seen: + raise ValueError('runtime archive contains duplicate normalized paths') + seen.add(key) + + +def runtime_members(archive: tarfile.TarFile) -> dict[str, tarfile.TarInfo]: + members = archive.getmembers() + # Ordinary TAR writers may include directories with one trailing slash. + # Normalize only that representation; every file name remains exact. + names = [member.name[:-1] if member.isdir() and member.name.endswith('/') + else member.name for member in members] + validate_names(names) + if any(not member.isfile() and not member.isdir() for member in members): + raise ValueError('runtime archive contains a linked or nonregular member') + return dict(zip(names, members)) diff --git a/tools/validate_artifacts.py b/tools/validate_artifacts.py index 476eb99..3acac94 100644 --- a/tools/validate_artifacts.py +++ b/tools/validate_artifacts.py @@ -161,6 +161,13 @@ def validate(target: Target) -> list[str]: if info.get("target") != target.name: problems.append(f"build-info target is {info.get('target')!r}") + if 'vulkan' in target.accelerators: + from vulkan_tri import validate_artifact + try: + validate_artifact(library, info.get('vulkanTriF16')) + except ValueError as error: + problems.append(str(error)) + expected = set(api_symbols(*HEADERS)) exported = exported_symbols(target, library) missing = sorted(expected - exported) diff --git a/tools/vulkan_tri.py b/tools/vulkan_tri.py new file mode 100644 index 0000000..c83e026 --- /dev/null +++ b/tools/vulkan_tri.py @@ -0,0 +1,196 @@ +"""Narrow validation of the compiled tri_f16 rejected-triangle zero store.""" +from __future__ import annotations + +import hashlib +import struct +from pathlib import Path + +MAGIC = 0x07230203 + + +def _validate_flow(data: bytes) -> None: + if len(data) < 20 or len(data) % 4: + raise ValueError('tri_f16: malformed SPIR-V size') + words = struct.unpack(f'<{len(data) // 4}I', data) + if words[0] != MAGIC or words[4] != 0: + raise ValueError('tri_f16: malformed SPIR-V header') + instructions, at = [], 5 + while at < len(words): + count, op = words[at] >> 16, words[at] & 65535 + if count == 0 or at + count > len(words): + raise ValueError('tri_f16: malformed SPIR-V instruction') + instructions.append((op, words[at + 1:at + count])) + at += count + if instructions[-1][0] != 56 or sum(op == 54 for op, _ in instructions) != 1: + raise ValueError('tri_f16: incomplete or ambiguous entry function') + floats = {args[0]: args[1] for op, args in instructions if op == 22 and len(args) == 2} + constants = {args[1]: (args[0], args[2:]) for op, args in instructions if op == 43 and len(args) >= 3} + pointers = {args[0]: args[1:] for op, args in instructions if op == 32 and len(args) == 3} + bindings = {args[0]: args[2] for op, args in instructions if op == 71 and len(args) == 3 and args[1] == 33} + # The operation selects four triangle modes, then either copies binding 0 + # to binding 1 or writes zero to binding 1. Different layouts need review. + switches = [args for op, args in instructions if op == 251 and len(args) == 10 + and set(args[2::2]) == {0, 1, 2, 3}] + if len(switches) != 1 or sorted(bindings.values()) != [0, 1]: + raise ValueError('tri_f16: missing or ambiguous triangle operation') + blocks, current = {}, None + for op, args in instructions: + if op == 248: + current = args[0] + blocks[current] = [] + elif current is not None: + blocks[current].append((op, args)) + rejected = [] + copied = [] + for label, block in blocks.items(): + conversions = {args[1]: (args[0], args[2]) for op, args in block if op == 115 and len(args) == 3} + access = {args[1]: (args[0], args[2]) for op, args in block if op == 65 and len(args) >= 4} + for op, args in block: + if op != 62 or len(args) != 2 or args[1] not in conversions or args[0] not in access: + continue + result_type, value = conversions[args[1]] + pointer_type, base = access[args[0]] + if floats.get(result_type) != 16 or pointers.get(pointer_type) != (12, result_type) or bindings.get(base) != 1: + continue + if value in constants: + type_id, literal = constants[value] + if floats.get(type_id) != 32 or literal != (0,): + raise ValueError('tri_f16: rejected triangle does not store positive zero') + # This constant must feed only this conversion, avoiding unused + # zero constants masking a malformed output data path. + uses = sum(1 for other_op, other_args in instructions + if other_op == 115 and len(other_args) == 3 and other_args[2] == value) + if uses != 1: + raise ValueError('tri_f16: ambiguous zero conversion') + rejected.append(label) + else: + # The source half is widened to float before narrowing for output. + if value in conversions: + widened_type, value = conversions[value] + if floats.get(widened_type) != 32: + continue + loads = {a[1]: a[2] for o, a in block if o == 61 and len(a) >= 3} + if value in loads and loads[value] in access: + _, input_base = access[loads[value]] + if bindings.get(input_base) == 0: + copied.append(label) + branches = [(args[1], args[2]) for op, args in instructions if op == 250 and len(args) in (3, 5)] + if len(rejected) != 1 or len(copied) != 1 or (copied[0], rejected[0]) not in branches: + raise ValueError('tri_f16: missing or ambiguous accepted/rejected output branches') + + +# Normalizing only the rejected-branch float constant admits the observed +# compiler defect for identification, while flow validation still rejects it. +KNOWN_LAYOUTS = { + "a8691344947943aa581022ecfc95953c02ea3247f85abd0d2f46a23ad49cbe0f", + "c38f10b1d14aa4078c2508fd3aa4ad10a799c02dd64dc2eb7f8e50f0ede4e2f7", +} +GENERATED_MODULE = Path('sd/ggml/src/ggml-vulkan/vulkan-shaders.spv/tri_f16.spv') + + +def normalized_identity(data: bytes) -> str | None: + if len(data) < 20 or len(data) % 4: + return None + words = list(struct.unpack(f'<{len(data) // 4}I', data)) + floats, constants, conversions = {}, {}, [] + at = 5 + while at < len(words): + count, op = words[at] >> 16, words[at] & 65535 + if not count or at + count > len(words): + return None + args = words[at + 1:at + count] + if op == 22 and len(args) == 2: + floats[args[0]] = args[1] + elif op == 43 and len(args) == 3: + constants[args[1]] = (args[0], at + 3) + elif op == 115 and len(args) == 3: + conversions.append((args[0], args[2])) + at += count + identities = [] + for result_type, value in conversions: + if floats.get(result_type) == 16 and value in constants: + source_type, offset = constants[value] + if floats.get(source_type) == 32: + normalized = words[:] + normalized[offset] = 0 + digest = hashlib.sha256(struct.pack(f'<{len(words)}I', *normalized)).hexdigest() + if digest in KNOWN_LAYOUTS: + identities.append(digest) + return identities[0] if len(identities) == 1 else None + + +def validate_module(data: bytes) -> None: + try: + _validate_flow(data) + except (IndexError, struct.error) as error: + raise ValueError('tri_f16: malformed instruction operands') from error + if normalized_identity(data) is None: + raise ValueError('tri_f16: unrecognized module layout; review compiler output') + + +def embedded_modules(data: bytes) -> tuple[list[bytes], int]: + found, unknown, position = [], 0, 0 + while True: + start = data.find(b'\x03\x02\x23\x07', position) + if start < 0: + break + position = start + 4 + if start + 20 > len(data): + continue + header = struct.unpack_from('<5I', data, start) + if not (0x10000 <= header[1] <= 0x10600 and header[4] == 0): + continue + at = start + 20 + while at + 4 <= len(data) and at - start < 1000000: + word = struct.unpack_from('> 16 + if not count or at + count * 4 > len(data): + break + at += count * 4 + if word & 65535 == 56: + module = data[start:at] + if normalized_identity(module) is not None: + found.append(module) + else: + try: + _validate_flow(module) + unknown += 1 + except (ValueError, IndexError, struct.error) as error: + if 'does not store positive zero' in str(error): + unknown += 1 + break + return found, unknown + + +def build_record(build_root: Path) -> dict: + path = build_root / GENERATED_MODULE + if not path.is_file(): + raise ValueError('tri_f16: generated module is missing') + data = path.read_bytes() + validate_module(data) + return {'schemaVersion': 1, 'sha256': hashlib.sha256(data).hexdigest(), 'spirv_hex': data.hex()} + + +def validate_artifact_bytes(binary: bytes, record: dict | None, *, allow_legacy: bool = False) -> None: + found, unknown = embedded_modules(binary) + if len(found) != 1 or unknown: + raise ValueError('tri_f16: runtime module is missing, ambiguous or unrecognized') + data = found[0] + validate_module(data) + if record is None and allow_legacy: + return + try: + if type(record['schemaVersion']) is not int or record['schemaVersion'] != 1: + raise ValueError('tri_f16: unsupported compiled module schema') + captured = bytes.fromhex(record['spirv_hex']) + expected = record['sha256'] + except (KeyError, TypeError, ValueError) as error: + raise ValueError('tri_f16: missing compiled module provenance') from error + if hashlib.sha256(captured).hexdigest() != expected: + raise ValueError('tri_f16: compiled module digest mismatch') + if captured != data: + raise ValueError('tri_f16: captured module differs from packaged runtime') + + +def validate_artifact(library: Path, record: dict | None, *, allow_legacy: bool = False) -> None: + validate_artifact_bytes(library.read_bytes(), record, allow_legacy=allow_legacy)