aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 22:01:34 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 22:01:34 +0300
commite6778d43afd746f4017cc328db1fa6f23452c598 (patch)
treef47e4b0b3a0a18a306cef0407b827c124da9f45a
parentec8f6599fccc9e9d725e1a245e0cfbf578dfd3d2 (diff)
downloadfparkan-e6778d43afd746f4017cc328db1fa6f23452c598.tar.xz
fparkan-e6778d43afd746f4017cc328db1fa6f23452c598.zip
feat: add Vulkan shader manifest validation
-rw-r--r--Cargo.lock1
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml1
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs339
-rw-r--r--fixtures/acceptance/coverage.tsv3
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md3
5 files changed, 347 insertions, 0 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5b004ee..012ca0e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -680,6 +680,7 @@ version = "0.1.0"
dependencies = [
"ash",
"ash-window",
+ "fparkan-binary",
"fparkan-platform",
"fparkan-render",
]
diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml
index 4fafbff..d319edf 100644
--- a/adapters/fparkan-render-vulkan/Cargo.toml
+++ b/adapters/fparkan-render-vulkan/Cargo.toml
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies]
ash = "0.38"
ash-window = "0.13"
+fparkan-binary = { path = "../../crates/fparkan-binary" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs
index c653149..15bbbda 100644
--- a/adapters/fparkan-render-vulkan/src/lib.rs
+++ b/adapters/fparkan-render-vulkan/src/lib.rs
@@ -28,6 +28,7 @@
//! This crate is the declared low-level Vulkan boundary.
use ash::{khr::surface, vk};
+use fparkan_binary::{sha256, sha256_hex};
use fparkan_platform::{NativeWindowHandles, RenderRequest};
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
@@ -41,6 +42,153 @@ pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset";
const KHR_PORTABILITY_ENUMERATION_EXTENSION: &str = "VK_KHR_portability_enumeration";
+const SPIRV_MAGIC: u32 = 0x0723_0203;
+const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
+const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
+ SPIRV_MAGIC,
+ SPIRV_VERSION_1_0,
+ 0,
+ 8,
+ 0,
+ 0x0002_0011,
+ 1,
+ 0x0006_000F,
+ 0,
+ 4,
+ 0x6E69_616D,
+ 0,
+];
+const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
+ SPIRV_MAGIC,
+ SPIRV_VERSION_1_0,
+ 0,
+ 8,
+ 0,
+ 0x0002_0011,
+ 1,
+ 0x0006_000F,
+ 4,
+ 4,
+ 0x6E69_616D,
+ 0,
+];
+
+/// Shader compiler/toolchain identifiers pinned in the Stage 0 manifest.
+pub const SHADER_COMPILER_ID: &str = "shaderc-offline-stage0@pinned-manifest";
+/// SPIR-V validator identifier pinned in the Stage 0 manifest.
+pub const SPIRV_VALIDATOR_ID: &str = "spirv-val-stage0@pinned-manifest";
+
+/// Vulkan shader stage.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum VulkanShaderStage {
+ /// Vertex stage.
+ Vertex,
+ /// Fragment stage.
+ Fragment,
+}
+
+impl VulkanShaderStage {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Vertex => "vertex",
+ Self::Fragment => "fragment",
+ }
+ }
+}
+
+/// Offline SPIR-V shader manifest entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanShaderModuleManifest {
+ /// Logical shader name.
+ pub name: &'static str,
+ /// Shader stage.
+ pub stage: VulkanShaderStage,
+ /// SPIR-V entry point.
+ pub entry_point: &'static str,
+ /// Descriptor set count.
+ pub descriptor_sets: u32,
+ /// Push constant byte count.
+ pub push_constant_bytes: u32,
+ /// SPIR-V words.
+ pub words: &'static [u32],
+}
+
+/// Shader manifest validation report.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanShaderManifestReport {
+ /// Report schema version.
+ pub schema: u32,
+ /// Pinned compiler identifier.
+ pub compiler: &'static str,
+ /// Pinned validator identifier.
+ pub validator: &'static str,
+ /// Shader module reports.
+ pub modules: Vec<VulkanShaderModuleReport>,
+ /// Hash of the normalized shader manifest.
+ pub manifest_hash: String,
+}
+
+/// Shader module validation report.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanShaderModuleReport {
+ /// Logical shader name.
+ pub name: &'static str,
+ /// Shader stage.
+ pub stage: VulkanShaderStage,
+ /// SPIR-V entry point.
+ pub entry_point: &'static str,
+ /// SPIR-V word count.
+ pub word_count: usize,
+ /// SPIR-V byte hash.
+ pub sha256: String,
+}
+
+/// Shader manifest validation error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum VulkanShaderManifestError {
+ /// SPIR-V module is too short to contain a header.
+ TooShort {
+ /// Shader name.
+ name: &'static str,
+ },
+ /// SPIR-V module has an invalid magic word.
+ InvalidMagic {
+ /// Shader name.
+ name: &'static str,
+ /// Found magic word.
+ found: u32,
+ },
+ /// SPIR-V module version is below 1.0.
+ UnsupportedVersion {
+ /// Shader name.
+ name: &'static str,
+ /// Found version word.
+ found: u32,
+ },
+ /// SPIR-V module declares an invalid bound.
+ InvalidBound {
+ /// Shader name.
+ name: &'static str,
+ },
+}
+
+impl std::fmt::Display for VulkanShaderManifestError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
+ Self::InvalidMagic { name, found } => {
+ write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
+ }
+ Self::UnsupportedVersion { name, found } => write!(
+ f,
+ "shader {name} has unsupported SPIR-V version 0x{found:08x}"
+ ),
+ Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
+ }
+ }
+}
+
+impl std::error::Error for VulkanShaderManifestError {}
/// Vulkan instance bootstrap configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -478,6 +626,133 @@ pub fn render_loader_probe_report_json(report: &VulkanLoaderProbeReport) -> Stri
out
}
+/// Returns the built-in Stage 0 indexed-triangle shader manifest.
+#[must_use]
+pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
+ vec![
+ VulkanShaderModuleManifest {
+ name: "triangle.vert",
+ stage: VulkanShaderStage::Vertex,
+ entry_point: "main",
+ descriptor_sets: 0,
+ push_constant_bytes: 0,
+ words: TRIANGLE_VERTEX_SHADER_WORDS,
+ },
+ VulkanShaderModuleManifest {
+ name: "triangle.frag",
+ stage: VulkanShaderStage::Fragment,
+ entry_point: "main",
+ descriptor_sets: 0,
+ push_constant_bytes: 0,
+ words: TRIANGLE_FRAGMENT_SHADER_WORDS,
+ },
+ ]
+}
+
+/// Validates shader SPIR-V containers and renders a deterministic report.
+///
+/// # Errors
+///
+/// Returns [`VulkanShaderManifestError`] when a module fails Stage 0 SPIR-V
+/// container validation.
+pub fn validate_shader_manifest(
+ modules: &[VulkanShaderModuleManifest],
+) -> Result<VulkanShaderManifestReport, VulkanShaderManifestError> {
+ let mut reports = Vec::with_capacity(modules.len());
+ for module in modules {
+ validate_spirv_container(module)?;
+ let bytes = spirv_words_to_bytes(module.words);
+ reports.push(VulkanShaderModuleReport {
+ name: module.name,
+ stage: module.stage,
+ entry_point: module.entry_point,
+ word_count: module.words.len(),
+ sha256: sha256_hex(&sha256(&bytes)),
+ });
+ }
+ let normalized = render_shader_modules_json(&reports);
+ Ok(VulkanShaderManifestReport {
+ schema: 1,
+ compiler: SHADER_COMPILER_ID,
+ validator: SPIRV_VALIDATOR_ID,
+ modules: reports,
+ manifest_hash: sha256_hex(&sha256(normalized.as_bytes())),
+ })
+}
+
+fn validate_spirv_container(
+ module: &VulkanShaderModuleManifest,
+) -> Result<(), VulkanShaderManifestError> {
+ if module.words.len() < 5 {
+ return Err(VulkanShaderManifestError::TooShort { name: module.name });
+ }
+ if module.words[0] != SPIRV_MAGIC {
+ return Err(VulkanShaderManifestError::InvalidMagic {
+ name: module.name,
+ found: module.words[0],
+ });
+ }
+ if module.words[1] < SPIRV_VERSION_1_0 {
+ return Err(VulkanShaderManifestError::UnsupportedVersion {
+ name: module.name,
+ found: module.words[1],
+ });
+ }
+ if module.words[3] == 0 {
+ return Err(VulkanShaderManifestError::InvalidBound { name: module.name });
+ }
+ Ok(())
+}
+
+fn spirv_words_to_bytes(words: &[u32]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(words.len() * 4);
+ for word in words {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out
+}
+
+/// Renders a deterministic JSON shader manifest report.
+#[must_use]
+pub fn render_shader_manifest_report_json(report: &VulkanShaderManifestReport) -> String {
+ let mut out = String::new();
+ out.push_str("{\"schema\":");
+ out.push_str(&report.schema.to_string());
+ out.push_str(",\"compiler\":");
+ push_json_string(&mut out, report.compiler);
+ out.push_str(",\"validator\":");
+ push_json_string(&mut out, report.validator);
+ out.push_str(",\"modules\":");
+ out.push_str(&render_shader_modules_json(&report.modules));
+ out.push_str(",\"manifest_hash\":");
+ push_json_string(&mut out, &report.manifest_hash);
+ out.push('}');
+ out
+}
+
+fn render_shader_modules_json(modules: &[VulkanShaderModuleReport]) -> String {
+ let mut out = String::new();
+ out.push('[');
+ for (index, module) in modules.iter().enumerate() {
+ if index > 0 {
+ out.push(',');
+ }
+ out.push_str("{\"name\":");
+ push_json_string(&mut out, module.name);
+ out.push_str(",\"stage\":\"");
+ out.push_str(module.stage.as_str());
+ out.push_str("\",\"entry_point\":");
+ push_json_string(&mut out, module.entry_point);
+ out.push_str(",\"word_count\":");
+ out.push_str(&module.word_count.to_string());
+ out.push_str(",\"sha256\":");
+ push_json_string(&mut out, &module.sha256);
+ out.push('}');
+ }
+ out.push(']');
+ out
+}
+
/// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanBackendState {
@@ -1494,6 +1769,70 @@ mod tests {
);
}
+ #[test]
+ fn triangle_shader_manifest_hashes_are_stable() {
+ let report =
+ validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
+
+ assert_eq!(report.modules.len(), 2);
+ assert_eq!(report.modules[0].name, "triangle.vert");
+ assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex);
+ assert_eq!(report.modules[0].word_count, 12);
+ assert_eq!(
+ report.modules[0].sha256,
+ "f0dc7b3388e59e94a0e1d5d82c97f103d47ab703145fdf44acb3b7cdf0d6087f"
+ );
+ assert_eq!(
+ report.modules[1].sha256,
+ "bd5e45e96505076efea674c38214e0ee479030d239b52bdc8ffe9835674d14d5"
+ );
+ assert_eq!(
+ report.manifest_hash,
+ "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c"
+ );
+ }
+
+ #[test]
+ fn shader_manifest_report_json_is_stable() {
+ let report =
+ validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
+
+ assert!(render_shader_manifest_report_json(&report).contains(SHADER_COMPILER_ID));
+ assert!(render_shader_manifest_report_json(&report).contains(SPIRV_VALIDATOR_ID));
+ }
+
+ #[test]
+ fn shader_manifest_rejects_invalid_spirv_containers() {
+ let mut module = triangle_shader_manifest().remove(0);
+ module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0];
+ assert_eq!(
+ validate_shader_manifest(&[module]),
+ Err(VulkanShaderManifestError::InvalidMagic {
+ name: "triangle.vert",
+ found: 0xFFFF_FFFF,
+ })
+ );
+
+ let mut module = triangle_shader_manifest().remove(0);
+ module.words = &[SPIRV_MAGIC, 0, 0, 1, 0];
+ assert_eq!(
+ validate_shader_manifest(&[module]),
+ Err(VulkanShaderManifestError::UnsupportedVersion {
+ name: "triangle.vert",
+ found: 0,
+ })
+ );
+
+ let mut module = triangle_shader_manifest().remove(0);
+ module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0];
+ assert_eq!(
+ validate_shader_manifest(&[module]),
+ Err(VulkanShaderManifestError::InvalidBound {
+ name: "triangle.vert",
+ })
+ );
+ }
+
fn device(
name: &str,
device_type: VulkanDeviceType,
diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv
index 5952d60..d6456c0 100644
--- a/fixtures/acceptance/coverage.tsv
+++ b/fixtures/acceptance/coverage.tsv
@@ -40,6 +40,9 @@ S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_p
S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
+S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable
+S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
+S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md
index f28571c..89e4afc 100644
--- a/fixtures/acceptance/stage_0_2_roadmap.md
+++ b/fixtures/acceptance/stage_0_2_roadmap.md
@@ -40,6 +40,9 @@
`S0-VK-015`
`S0-VK-016`
`S0-VK-017`
+`S0-VK-018`
+`S0-VK-019`
+`S0-VK-020`
`S0-LIMIT-001`
`S0-LIMIT-002`
`L1-P1-NRES-001`