diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 22:01:34 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 22:01:34 +0300 |
| commit | e6778d43afd746f4017cc328db1fa6f23452c598 (patch) | |
| tree | f47e4b0b3a0a18a306cef0407b827c124da9f45a | |
| parent | ec8f6599fccc9e9d725e1a245e0cfbf578dfd3d2 (diff) | |
| download | fparkan-e6778d43afd746f4017cc328db1fa6f23452c598.tar.xz fparkan-e6778d43afd746f4017cc328db1fa6f23452c598.zip | |
feat: add Vulkan shader manifest validation
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/Cargo.toml | 1 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 339 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 3 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 3 |
5 files changed, 347 insertions, 0 deletions
@@ -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` |
