Skip to main content

par_term_render/custom_shader_renderer/
mod.rs

1//! Custom shader renderer for post-processing effects
2//!
3//! Supports Ghostty/Shadertoy-style GLSL shaders with the following uniforms:
4//! - `iTime`: Time in seconds (animated or fixed at 0.0)
5//! - `iResolution`: Viewport resolution (width, height, 1.0)
6//! - `iChannel0-3`: User texture channels (Shadertoy compatible)
7//! - `iChannel4`: Terminal content texture
8//! - `iTimeKeyPress`: Time when last key was pressed (same timebase as iTime)
9//!
10//! Ghostty-compatible cursor uniforms (v1.2.0+):
11//! - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
12//! - `iPreviousCursor`: Previous cursor position and size
13//! - `iCurrentCursorColor`: Current cursor RGBA color (with opacity baked in)
14//! - `iPreviousCursorColor`: Previous cursor RGBA color
15//! - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
16
17use anyhow::{Context, Result};
18use par_term_emu_core_rust::cursor::CursorStyle;
19use std::collections::BTreeMap;
20use std::path::Path;
21use std::time::Instant;
22use wgpu::util::DeviceExt;
23use wgpu::*;
24
25mod builtin_textures;
26mod cubemap;
27mod cursor;
28mod hot_reload;
29pub mod pipeline;
30mod state;
31pub mod textures;
32pub mod transpiler;
33pub mod types;
34mod uniforms;
35
36use cubemap::CubemapTexture;
37use pipeline::{
38    BindGroupInputs, create_bind_group, create_bind_group_layout, create_render_pipeline,
39};
40use textures::{ChannelTexture, load_channel_textures};
41use transpiler::transpile_glsl_to_wgsl;
42
43/// Path the transpiled WGSL is dumped to for shader debugging.
44///
45/// Resolved through [`crate::shader_debug`] so the renderer, the transpiler and the
46/// paths reported by shader diagnostics all name the same file: `$TMPDIR` on Unix (a
47/// per-user directory on macOS, `/tmp` on Linux), `%TEMP%` on Windows. A hardcoded
48/// `/tmp` was wrong on Windows, where it resolves against the current drive.
49#[cfg_attr(not(debug_assertions), allow(dead_code))]
50fn debug_shader_wgsl_filename(shader_name: &str) -> String {
51    crate::shader_debug::transpiled_wgsl_path(shader_name)
52        .to_string_lossy()
53        .into_owned()
54}
55
56/// Dump the transpiled WGSL next to the transpiler's wrapped-GLSL dump.
57///
58/// Debug builds only, matching the wrapped-GLSL dump in
59/// [`transpiler::wgsl_emit`], and written owner-only: on Linux the temp directory
60/// is the shared, world-writable `/tmp`, so a predictable filename there is both a
61/// disclosure and a symlink hazard. `create_new` gives `O_CREAT | O_EXCL`, which
62/// POSIX requires to fail rather than follow a symlink, so a pre-planted link is
63/// rejected atomically; the preceding unlink is what lets a real dump be refreshed
64/// on the next shader load.
65fn write_debug_shader_wgsl(shader_name: &str, wgsl_source: &str) {
66    #[cfg(debug_assertions)]
67    {
68        let debug_filename = debug_shader_wgsl_filename(shader_name);
69
70        // Removes our own previous dump, and a symlink planted in its place (this
71        // unlinks the link itself, never its target). Losing the race is handled by
72        // `create_new` below failing rather than writing through the attacker's link.
73        let _ = std::fs::remove_file(&debug_filename);
74
75        let mut opts = std::fs::OpenOptions::new();
76        opts.write(true).create_new(true);
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::OpenOptionsExt;
80            opts.mode(0o600);
81        }
82
83        let result = opts
84            .open(&debug_filename)
85            .and_then(|mut f| std::io::Write::write_all(&mut f, wgsl_source.as_bytes()));
86
87        match result {
88            Ok(()) => log::info!("Wrote debug shader to {}", debug_filename),
89            Err(e) => log::warn!("Failed to write debug shader: {}", e),
90        }
91    }
92    #[cfg(not(debug_assertions))]
93    {
94        let _ = (shader_name, wgsl_source);
95    }
96}
97
98fn animation_start_after_enabled_update(
99    currently_enabled: bool,
100    enabled: bool,
101    current_start: Instant,
102    now: Instant,
103) -> Instant {
104    if enabled && !currently_enabled {
105        now
106    } else {
107        current_start
108    }
109}
110
111/// Custom shader renderer that applies post-processing effects
112pub struct CustomShaderRenderer {
113    /// The render pipeline for the custom shader
114    pub(crate) pipeline: RenderPipeline,
115    /// Bind group for shader uniforms and textures
116    pub(crate) bind_group: BindGroup,
117    /// Uniform buffer for shader parameters
118    pub(crate) uniform_buffer: Buffer,
119    /// Uniform buffer for custom shader control values
120    pub(crate) custom_uniform_buffer: Buffer,
121    /// Intermediate texture to render terminal content into
122    pub(crate) intermediate_texture: Texture,
123    /// View of the intermediate texture
124    pub(crate) intermediate_texture_view: TextureView,
125    /// Start time for animation
126    pub(crate) start_time: Instant,
127    /// Whether animation is enabled
128    pub(crate) animation_enabled: bool,
129    /// Animation speed multiplier
130    pub(crate) animation_speed: f32,
131    /// Current texture dimensions
132    pub(crate) texture_width: u32,
133    pub(crate) texture_height: u32,
134    /// Surface format for compatibility
135    pub(crate) surface_format: TextureFormat,
136    /// Bind group layout for recreating bind groups on resize
137    pub(crate) bind_group_layout: BindGroupLayout,
138    /// Sampler for the intermediate texture
139    pub(crate) sampler: Sampler,
140    /// Display scale factor for DPI scaling (e.g., 2.0 on Retina)
141    pub(crate) scale_factor: f32,
142    /// Window opacity for transparency
143    pub(crate) window_opacity: f32,
144    /// When true, text is always rendered at full opacity (overrides text_opacity)
145    pub(crate) keep_text_opaque: bool,
146    /// Full content mode - shader receives and can manipulate full terminal content
147    pub(crate) full_content_mode: bool,
148    /// Brightness multiplier for shader output (0.05-1.0)
149    pub(crate) brightness: f32,
150    /// Whether to dim shader output under terminal content.
151    pub(crate) auto_dim_under_text: bool,
152    /// Strength of dimming under terminal content.
153    pub(crate) auto_dim_strength: f32,
154    /// Frame counter for iFrame uniform
155    pub(crate) frame_count: u32,
156    /// Last frame time for calculating time delta
157    pub(crate) last_frame_time: Instant,
158    /// Current mouse position in pixels (xy)
159    pub(crate) mouse_position: [f32; 2],
160    /// Last click position in pixels (zw)
161    pub(crate) mouse_click_position: [f32; 2],
162    /// Whether mouse button is currently pressed (affects sign of zw)
163    pub(crate) mouse_button_down: bool,
164    /// Frame rate tracking: time accumulator for averaging
165    pub(crate) frame_time_accumulator: f32,
166    /// Frame rate tracking: frames in current second
167    pub(crate) frames_in_second: u32,
168    /// Current smoothed frame rate
169    pub(crate) current_frame_rate: f32,
170
171    // ============ Cursor tracking (Ghostty-compatible) ============
172    /// Current cursor position in cell coordinates (col, row)
173    pub(crate) current_cursor_pos: (usize, usize),
174    /// Previous cursor position in cell coordinates
175    pub(crate) previous_cursor_pos: (usize, usize),
176    /// Current cursor RGBA color
177    pub(crate) current_cursor_color: [f32; 4],
178    /// Previous cursor RGBA color
179    pub(crate) previous_cursor_color: [f32; 4],
180    /// Current cursor opacity (0.0 = invisible, 1.0 = fully visible)
181    pub(crate) current_cursor_opacity: f32,
182    /// Previous cursor opacity
183    pub(crate) previous_cursor_opacity: f32,
184    /// Time when cursor position last changed (same timebase as iTime)
185    pub(crate) cursor_change_time: f32,
186    /// Current cursor style (for size calculation)
187    pub(crate) current_cursor_style: CursorStyle,
188    /// Previous cursor style
189    pub(crate) previous_cursor_style: CursorStyle,
190    /// Cell width in pixels (for cursor position calculation)
191    pub(crate) cursor_cell_width: f32,
192    /// Cell height in pixels (for cursor position calculation)
193    pub(crate) cursor_cell_height: f32,
194    /// Window padding in pixels (for cursor position calculation)
195    pub(crate) cursor_window_padding: f32,
196    /// Vertical content offset in pixels (e.g., tab bar height)
197    pub(crate) cursor_content_offset_y: f32,
198    /// Horizontal content offset in pixels (e.g., tab bar on left)
199    pub(crate) cursor_content_offset_x: f32,
200
201    // ============ Cursor shader configuration ============
202    /// User-configured cursor color for shader effects [R, G, B, A]
203    pub(crate) cursor_shader_color: [f32; 4],
204    /// Cursor trail duration in seconds
205    pub(crate) cursor_trail_duration: f32,
206    /// Cursor glow radius in pixels
207    pub(crate) cursor_glow_radius: f32,
208    /// Cursor glow intensity (0.0-1.0)
209    pub(crate) cursor_glow_intensity: f32,
210
211    // ============ Key press tracking ============
212    /// Time when a key was last pressed (same timebase as iTime)
213    pub(crate) key_press_time: f32,
214
215    // ============ Channel textures (iChannel0-3) ============
216    /// Texture channels 0-3 (placeholders or loaded textures, Shadertoy compatible)
217    pub(crate) channel_textures: [ChannelTexture; 4],
218
219    // ============ Cubemap texture (iCubemap) ============
220    /// Cubemap texture for environment mapping (placeholder or loaded)
221    pub(crate) cubemap: CubemapTexture,
222
223    // ============ Background image as iChannel0 ============
224    /// When true, use the background image texture as iChannel0 instead of the configured texture
225    pub(crate) use_background_as_channel0: bool,
226    /// Background texture to use as iChannel0 when use_background_as_channel0 is true
227    /// This is a reference texture (view + sampler + dimensions) from the cell renderer
228    pub(crate) background_channel_texture: Option<ChannelTexture>,
229    /// Blend mode hint exposed to shaders for background-as-iChannel0 composition.
230    pub(crate) background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
231
232    // ============ Solid background color ============
233    /// Solid background color [R, G, B, A] for shader compositing.
234    /// When A > 0, the shader uses this color as background instead of shader output.
235    /// RGB values are NOT premultiplied.
236    pub(crate) background_color: [f32; 4],
237
238    // ============ Progress bar state ============
239    /// Progress bar data [state, percent, isActive, activeCount]
240    pub(crate) progress_data: [f32; 4],
241
242    // ============ Command state ============
243    /// Command state data [state, exitCode, eventTime, running]
244    pub(crate) command_data: [f32; 4],
245
246    // ============ Focused pane bounds ============
247    /// Focused pane bounds [x, y, width, height] in bottom-left-origin pixels.
248    pub(crate) focused_pane: [f32; 4],
249
250    // ============ Scrollback context ============
251    /// Scrollback context [offset, visibleLines, scrollbackLines, normalizedDepth]
252    pub(crate) scroll_data: [f32; 4],
253
254    // ============ Content inset for panels ============
255    /// Right content inset in pixels (e.g., AI Inspector panel).
256    /// The shader renders to a viewport offset by this amount from the left.
257    pub(crate) content_inset_right: f32,
258
259    // ============ Custom shader controls ============
260    /// Custom controls parsed from `// control ...` shader comments.
261    pub(crate) custom_controls: Vec<par_term_config::ShaderControl>,
262    /// Current custom uniform values keyed by control name.
263    pub(crate) custom_uniform_values: BTreeMap<String, par_term_config::ShaderUniformValue>,
264}
265
266/// Parameters for creating a new [`CustomShaderRenderer`].
267pub struct CustomShaderRendererConfig<'a> {
268    pub surface_format: TextureFormat,
269    pub shader_path: &'a Path,
270    pub width: u32,
271    pub height: u32,
272    pub animation_enabled: bool,
273    pub animation_speed: f32,
274    pub window_opacity: f32,
275    pub full_content_mode: bool,
276    pub channel_paths: &'a [Option<std::path::PathBuf>; 4],
277    pub cubemap_path: Option<&'a Path>,
278    pub custom_uniforms: &'a BTreeMap<String, par_term_config::ShaderUniformValue>,
279    pub background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
280}
281
282impl CustomShaderRenderer {
283    /// Create a new custom shader renderer from a GLSL shader file.
284    pub fn new(
285        device: &Device,
286        queue: &Queue,
287        config: CustomShaderRendererConfig<'_>,
288    ) -> Result<Self> {
289        let CustomShaderRendererConfig {
290            surface_format,
291            shader_path,
292            width,
293            height,
294            animation_enabled,
295            animation_speed,
296            window_opacity,
297            full_content_mode,
298            channel_paths,
299            cubemap_path,
300            custom_uniforms,
301            background_channel0_blend_mode,
302        } = config;
303        // Load the GLSL shader
304        let glsl_source = std::fs::read_to_string(shader_path)
305            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
306
307        let control_parse = par_term_config::parse_shader_controls(&glsl_source);
308        for warning in &control_parse.warnings {
309            log::warn!(
310                "Shader control warning line {}: {}",
311                warning.line,
312                warning.message
313            );
314        }
315        let custom_controls = control_parse.controls;
316        let custom_uniform_values = custom_uniforms.clone();
317
318        // Transpile GLSL to WGSL
319        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
320
321        log::info!(
322            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
323            shader_path.display(),
324            glsl_source.len(),
325            wgsl_source.len()
326        );
327        log::debug!("Generated WGSL:\n{}", wgsl_source);
328
329        // DEBUG: Write generated WGSL to file for inspection
330        let shader_name = shader_path
331            .file_stem()
332            .and_then(|s| s.to_str())
333            .unwrap_or("unknown");
334        write_debug_shader_wgsl(shader_name, &wgsl_source);
335
336        // Pre-validate WGSL
337        let module = naga::front::wgsl::parse_str(&wgsl_source)
338            .context("Custom shader WGSL parse failed")?;
339        let _info = naga::valid::Validator::new(
340            naga::valid::ValidationFlags::all(),
341            naga::valid::Capabilities::empty(),
342        )
343        .validate(&module)
344        .context("Custom shader WGSL validation failed")?;
345
346        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
347            label: Some("Custom Shader Module"),
348            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
349        });
350
351        // Create intermediate texture for terminal content
352        let (intermediate_texture, intermediate_texture_view) =
353            Self::create_intermediate_texture(device, surface_format, width, height);
354
355        // Create sampler for the intermediate texture (terminal content)
356        // Use Nearest filtering to keep text crisp and pixel-perfect
357        let sampler = device.create_sampler(&SamplerDescriptor {
358            label: Some("Custom Shader Sampler"),
359            address_mode_u: AddressMode::ClampToEdge,
360            address_mode_v: AddressMode::ClampToEdge,
361            address_mode_w: AddressMode::ClampToEdge,
362            mag_filter: FilterMode::Nearest,
363            min_filter: FilterMode::Nearest,
364            mipmap_filter: MipmapFilterMode::Nearest,
365            ..Default::default()
366        });
367
368        // Load channel textures (iChannel0-3)
369        let channel_textures = load_channel_textures(device, queue, channel_paths);
370
371        // Load cubemap texture (iCubemap)
372        let cubemap = match cubemap_path {
373            Some(path) => match CubemapTexture::from_prefix(device, queue, path) {
374                Ok(cm) => cm,
375                Err(e) => {
376                    log::error!("Failed to load cubemap '{}': {}", path.display(), e);
377                    CubemapTexture::placeholder(device, queue)
378                }
379            },
380            None => CubemapTexture::placeholder(device, queue),
381        };
382
383        // Create uniform buffers
384        let uniform_buffer = Self::create_uniform_buffer(device);
385        let custom_uniform_data =
386            crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
387                &custom_controls,
388                &custom_uniform_values,
389            );
390        let custom_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
391            label: Some("Custom Shader Control Uniform Buffer"),
392            contents: bytemuck::cast_slice(&[custom_uniform_data]),
393            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
394        });
395
396        // Create bind group layout and bind group
397        let bind_group_layout = create_bind_group_layout(device);
398        let bind_group = create_bind_group(
399            device,
400            BindGroupInputs {
401                layout: &bind_group_layout,
402                uniform_buffer: &uniform_buffer,
403                intermediate_texture_view: &intermediate_texture_view,
404                custom_uniform_buffer: &custom_uniform_buffer,
405                sampler: &sampler,
406                channel_textures: &channel_textures,
407                cubemap: &cubemap,
408            },
409        );
410
411        // Create render pipeline
412        let pipeline = create_render_pipeline(
413            device,
414            &shader_module,
415            &bind_group_layout,
416            surface_format,
417            Some("Custom Shader Pipeline"),
418        );
419
420        let now = Instant::now();
421        Ok(Self {
422            pipeline,
423            bind_group,
424            uniform_buffer,
425            custom_uniform_buffer,
426            intermediate_texture,
427            intermediate_texture_view,
428            start_time: now,
429            animation_enabled,
430            animation_speed,
431            texture_width: width,
432            texture_height: height,
433            surface_format,
434            bind_group_layout,
435            sampler,
436            window_opacity,
437            keep_text_opaque: false,
438            scale_factor: 1.0,
439            full_content_mode,
440            brightness: 1.0,
441            auto_dim_under_text: false,
442            auto_dim_strength: 0.35,
443            frame_count: 0,
444            last_frame_time: now,
445            mouse_position: [0.0, 0.0],
446            mouse_click_position: [0.0, 0.0],
447            mouse_button_down: false,
448            frame_time_accumulator: 0.0,
449            frames_in_second: 0,
450            current_frame_rate: 60.0,
451            current_cursor_pos: (0, 0),
452            previous_cursor_pos: (0, 0),
453            current_cursor_color: [1.0, 1.0, 1.0, 1.0],
454            previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
455            current_cursor_opacity: 1.0,
456            previous_cursor_opacity: 1.0,
457            cursor_change_time: 0.0,
458            current_cursor_style: CursorStyle::SteadyBlock,
459            previous_cursor_style: CursorStyle::SteadyBlock,
460            cursor_cell_width: 10.0,
461            cursor_cell_height: 20.0,
462            cursor_window_padding: 0.0,
463            cursor_content_offset_y: 0.0,
464            cursor_content_offset_x: 0.0,
465            cursor_shader_color: [1.0, 1.0, 1.0, 1.0],
466            cursor_trail_duration: 0.5,
467            cursor_glow_radius: 80.0,
468            cursor_glow_intensity: 0.3,
469            key_press_time: 0.0,
470            channel_textures,
471            cubemap,
472            use_background_as_channel0: false,
473            background_channel_texture: None,
474            background_channel0_blend_mode,
475            background_color: [0.0, 0.0, 0.0, 0.0], // No solid background by default
476            progress_data: [0.0, 0.0, 0.0, 0.0],
477            command_data: [0.0, 0.0, 0.0, 0.0],
478            focused_pane: [0.0, 0.0, width as f32, height as f32],
479            scroll_data: [0.0, 0.0, 0.0, 0.0],
480            content_inset_right: 0.0,
481            custom_controls,
482            custom_uniform_values,
483        })
484    }
485
486    /// Get a view of the intermediate texture for rendering terminal content into
487    pub fn intermediate_texture_view(&self) -> &TextureView {
488        &self.intermediate_texture_view
489    }
490
491    /// Render the custom shader effect to the output texture
492    ///
493    /// # Arguments
494    /// * `device` - The GPU device
495    /// * `queue` - The command queue
496    /// * `output_view` - The texture view to render to
497    /// * `apply_opacity` - Whether to apply window opacity. Set to `false` when rendering
498    ///   to an intermediate texture that will be processed by another shader (to avoid
499    ///   double-applying opacity).
500    pub fn render(
501        &mut self,
502        device: &Device,
503        queue: &Queue,
504        output_view: &TextureView,
505        apply_opacity: bool,
506    ) -> Result<()> {
507        self.render_with_clear_color(
508            device,
509            queue,
510            output_view,
511            apply_opacity,
512            Color::TRANSPARENT,
513        )
514    }
515
516    /// Render the custom shader with a specified clear color.
517    /// Use this for solid background colors where the clear color provides the background.
518    pub fn render_with_clear_color(
519        &mut self,
520        device: &Device,
521        queue: &Queue,
522        output_view: &TextureView,
523        apply_opacity: bool,
524        clear_color: Color,
525    ) -> Result<()> {
526        let now = Instant::now();
527
528        // Calculate time value
529        let time = if self.animation_enabled {
530            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
531        } else {
532            0.0
533        };
534
535        // Calculate time delta
536        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
537        self.last_frame_time = now;
538
539        // Update frame rate calculation
540        self.frame_time_accumulator += time_delta;
541        self.frames_in_second += 1;
542        if self.frame_time_accumulator >= 1.0 {
543            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
544            self.frame_time_accumulator = 0.0;
545            self.frames_in_second = 0;
546        }
547
548        self.frame_count = self.frame_count.wrapping_add(1);
549
550        // Calculate uniforms
551        let uniforms = self.build_uniforms(time, time_delta, apply_opacity);
552        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
553        let custom_uniforms =
554            crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
555                &self.custom_controls,
556                &self.custom_uniform_values,
557            );
558        queue.write_buffer(
559            &self.custom_uniform_buffer,
560            0,
561            bytemuck::cast_slice(&[custom_uniforms]),
562        );
563
564        // Create command encoder and render
565        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
566            label: Some("Custom Shader Encoder"),
567        });
568
569        {
570            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
571                label: Some("Custom Shader Render Pass"),
572                color_attachments: &[Some(RenderPassColorAttachment {
573                    view: output_view,
574                    resolve_target: None,
575                    ops: Operations {
576                        load: LoadOp::Clear(clear_color),
577                        store: StoreOp::Store,
578                    },
579                    depth_slice: None,
580                })],
581                depth_stencil_attachment: None,
582                timestamp_writes: None,
583                occlusion_query_set: None,
584                multiview_mask: None,
585            });
586
587            // Note: We intentionally do NOT set a viewport here to exclude the panel area.
588            // The viewport approach doesn't work because fragCoord in WGSL is relative to
589            // the render target, not the viewport, causing UV coordinate mismatches.
590            // The opaque panel (PANEL_BG with alpha 255) covers any shader output under it.
591
592            render_pass.set_pipeline(&self.pipeline);
593            render_pass.set_bind_group(0, &self.bind_group, &[]);
594            render_pass.draw(0..4, 0..1);
595        }
596
597        queue.submit(std::iter::once(encoder.finish()));
598        Ok(())
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use std::time::Duration;
606
607    #[test]
608    fn enabling_animation_when_already_enabled_preserves_start_time() {
609        let start_time = Instant::now() - Duration::from_secs(5);
610        let now = Instant::now();
611
612        assert_eq!(
613            animation_start_after_enabled_update(true, true, start_time, now),
614            start_time
615        );
616    }
617
618    #[test]
619    fn enabling_animation_from_disabled_starts_at_now() {
620        let start_time = Instant::now() - Duration::from_secs(5);
621        let now = Instant::now();
622
623        assert_eq!(
624            animation_start_after_enabled_update(false, true, start_time, now),
625            now
626        );
627    }
628
629    #[test]
630    fn debug_shader_wgsl_filename_matches_new_renderer_output_path() {
631        let path = debug_shader_wgsl_filename("matrix");
632        assert_eq!(
633            std::path::Path::new(&path),
634            crate::shader_debug::debug_dump_dir().join("par_term_matrix_shader.wgsl")
635        );
636    }
637
638    // The dump is debug-builds-only, so these exercise nothing under
639    // `cargo test --release`.
640    #[cfg(debug_assertions)]
641    #[test]
642    fn write_debug_shader_wgsl_refreshes_existing_output() {
643        let shader_name = format!("par_term_test_{}", std::process::id());
644        let path = debug_shader_wgsl_filename(&shader_name);
645        let _ = std::fs::remove_file(&path);
646
647        write_debug_shader_wgsl(&shader_name, "first");
648        write_debug_shader_wgsl(&shader_name, "second");
649
650        assert_eq!(
651            std::fs::read_to_string(&path).expect("read debug wgsl"),
652            "second"
653        );
654        std::fs::remove_file(&path).expect("remove debug wgsl");
655    }
656
657    /// SEC-006: on Linux the dump lands in the shared, world-writable `/tmp`, so a
658    /// predictable filename must not be readable by other users.
659    #[cfg(all(debug_assertions, unix))]
660    #[test]
661    fn write_debug_shader_wgsl_creates_owner_only_file() {
662        use std::os::unix::fs::PermissionsExt;
663
664        let shader_name = format!("par_term_mode_{}", std::process::id());
665        let path = debug_shader_wgsl_filename(&shader_name);
666        let _ = std::fs::remove_file(&path);
667
668        write_debug_shader_wgsl(&shader_name, "secret");
669
670        let mode = std::fs::metadata(&path)
671            .expect("dump exists")
672            .permissions()
673            .mode();
674        assert_eq!(mode & 0o777, 0o600, "dump must not be group/world readable");
675        std::fs::remove_file(&path).expect("remove debug wgsl");
676    }
677
678    /// SEC-006: a symlink pre-planted at the dump path must not be written through.
679    #[cfg(all(debug_assertions, unix))]
680    #[test]
681    fn write_debug_shader_wgsl_does_not_follow_a_planted_symlink() {
682        let shader_name = format!("par_term_link_{}", std::process::id());
683        let path = debug_shader_wgsl_filename(&shader_name);
684        let target = crate::shader_debug::debug_dump_dir()
685            .join(format!("par_term_link_target_{}", std::process::id()));
686
687        std::fs::write(&target, "original").expect("seed symlink target");
688        let _ = std::fs::remove_file(&path);
689        std::os::unix::fs::symlink(&target, &path).expect("plant symlink");
690
691        write_debug_shader_wgsl(&shader_name, "attacker-visible");
692
693        assert_eq!(
694            std::fs::read_to_string(&target).expect("read symlink target"),
695            "original",
696            "the dump must not be written through the planted symlink"
697        );
698        let _ = std::fs::remove_file(&path);
699        std::fs::remove_file(&target).expect("remove symlink target");
700    }
701}