Skip to main content

par_term_config/config/
path_validation.rs

1//! Path validation and shader-path helpers for `Config`.
2//!
3//! Covers:
4//! - Traversal-safe path validation (`validate_config_path`, `validate_shader_name`)
5//! - Shader directory / file path resolution (`shaders_dir`, `shader_path`, etc.)
6//! - Shader-channel and cubemap accessors
7//! - Shader config overrides
8//! - Shader install-prompt helpers
9
10use super::config_struct::Config;
11use crate::error::ConfigError;
12use crate::types::{CursorShaderConfig, ShaderConfig, ShaderInstallPrompt};
13use std::fs;
14use std::path::{Path, PathBuf};
15
16impl Config {
17    /// Validate that `path` (which must already exist on disk) resolves — via
18    /// `canonicalize` — to a location inside `expected_base`.
19    ///
20    /// Uses `std::fs::canonicalize` so symlinks are fully resolved before the
21    /// containment check.  Returns the canonical path on success, or a
22    /// [`ConfigError::PathTraversal`] error if the resolved path escapes the
23    /// expected directory.
24    ///
25    /// # Errors
26    ///
27    /// Returns `ConfigError::PathTraversal` when the canonical path does not
28    /// start with the canonical `expected_base`.
29    /// Returns `ConfigError::Io` if either path cannot be canonicalized.
30    pub fn validate_config_path(path: &Path, expected_base: &Path) -> Result<PathBuf, ConfigError> {
31        let canonical = fs::canonicalize(path).map_err(|e| {
32            std::io::Error::new(
33                e.kind(),
34                format!("cannot canonicalize {}: {e}", path.display()),
35            )
36        })?;
37
38        let canonical_base = fs::canonicalize(expected_base).unwrap_or_else(|_| {
39            // If the base doesn't exist yet (first run), use the un-resolved path.
40            expected_base.to_path_buf()
41        });
42
43        if !canonical.starts_with(&canonical_base) {
44            return Err(ConfigError::PathTraversal(format!(
45                "path '{}' resolves to '{}' which is outside the expected directory '{}'",
46                path.display(),
47                canonical.display(),
48                canonical_base.display(),
49            )));
50        }
51
52        Ok(canonical)
53    }
54
55    /// Lexically check that a **relative** shader name does not contain `..`
56    /// components that would escape the shaders directory.
57    ///
58    /// This is a compile-time / pre-existence check used by [`Self::shader_path`]
59    /// and [`Self::checked_shader_path`] before the file is opened.  Because the
60    /// shader file might not exist yet (e.g., when the user is composing its name
61    /// in the settings UI), we cannot call `canonicalize` here.
62    ///
63    /// Returns `Ok(())` when safe, `Err(ConfigError::PathTraversal)` when the
64    /// name contains a parent-directory component.
65    fn validate_shader_name(shader_name: &str) -> Result<(), ConfigError> {
66        use std::path::Component;
67
68        let path = Path::new(shader_name);
69
70        // Reject any component that steps upward.
71        for component in path.components() {
72            if component == Component::ParentDir {
73                return Err(ConfigError::PathTraversal(format!(
74                    "shader name '{shader_name}' contains a parent-directory component ('..') \
75                     which would escape the shaders directory",
76                )));
77            }
78        }
79
80        Ok(())
81    }
82
83    /// Get the shaders directory path (using XDG convention)
84    pub fn shaders_dir() -> PathBuf {
85        #[cfg(target_os = "windows")]
86        {
87            if let Some(config_dir) = dirs::config_dir() {
88                config_dir.join("par-term").join("shaders")
89            } else {
90                PathBuf::from("shaders")
91            }
92        }
93        #[cfg(not(target_os = "windows"))]
94        {
95            if let Some(home_dir) = dirs::home_dir() {
96                home_dir.join(".config").join("par-term").join("shaders")
97            } else {
98                PathBuf::from("shaders")
99            }
100        }
101    }
102
103    /// Get the full path to a shader file.
104    ///
105    /// If `shader_name` is an absolute path it is returned as-is (the user
106    /// explicitly chose a location outside the shaders directory).
107    ///
108    /// For relative names the path is resolved under [`Self::shaders_dir`].
109    /// Any relative name that contains `..` components is rejected: the
110    /// function logs a warning and falls back to returning the shaders directory
111    /// itself so that callers always receive a valid `PathBuf` without breaking
112    /// existing call sites.  Use [`Self::checked_shader_path`] when you need a
113    /// hard error instead of a fallback.
114    pub fn shader_path(shader_name: &str) -> PathBuf {
115        let path = PathBuf::from(shader_name);
116        if path.is_absolute() {
117            return path;
118        }
119
120        // Lexical traversal check for relative names.
121        if let Err(e) = Self::validate_shader_name(shader_name) {
122            log::warn!("{e} — falling back to shaders directory");
123            return Self::shaders_dir();
124        }
125
126        Self::shaders_dir().join(shader_name)
127    }
128
129    /// Get the full path to a shader file, returning an error if the name
130    /// would escape the shaders directory.
131    ///
132    /// This is a strict variant of [`Self::shader_path`] for callers that
133    /// prefer a hard error over a silent fallback.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`ConfigError::PathTraversal`] when a relative `shader_name`
138    /// contains a `..` component. Absolute paths are returned unchanged and are
139    /// never rejected — the caller is trusted to have vetted them.
140    pub fn checked_shader_path(shader_name: &str) -> Result<PathBuf, ConfigError> {
141        let path = PathBuf::from(shader_name);
142        if path.is_absolute() {
143            return Ok(path);
144        }
145
146        Self::validate_shader_name(shader_name)?;
147        Ok(Self::shaders_dir().join(shader_name))
148    }
149
150    /// Resolve a texture path, expanding ~ to home directory
151    /// and resolving relative paths relative to the shaders directory.
152    /// Built-in texture IDs are preserved for renderer-side resolution.
153    /// Returns the expanded path or the original if expansion fails
154    pub fn resolve_texture_path(path: &str) -> PathBuf {
155        if path.starts_with("builtin://") {
156            return PathBuf::from(path);
157        }
158        if path.starts_with("~/")
159            && let Some(home) = dirs::home_dir()
160        {
161            return home.join(&path[2..]);
162        }
163        let path_buf = PathBuf::from(path);
164        if path_buf.is_absolute() {
165            path_buf
166        } else {
167            Self::shaders_dir().join(path)
168        }
169    }
170
171    /// Get the channel texture paths as an array of Options
172    /// Returns [channel0, channel1, channel2, channel3] for iChannel0-3
173    pub fn shader_channel_paths(&self) -> [Option<PathBuf>; 4] {
174        [
175            self.shader
176                .custom_shader_channel0
177                .as_ref()
178                .map(|p| Self::resolve_texture_path(p)),
179            self.shader
180                .custom_shader_channel1
181                .as_ref()
182                .map(|p| Self::resolve_texture_path(p)),
183            self.shader
184                .custom_shader_channel2
185                .as_ref()
186                .map(|p| Self::resolve_texture_path(p)),
187            self.shader
188                .custom_shader_channel3
189                .as_ref()
190                .map(|p| Self::resolve_texture_path(p)),
191        ]
192    }
193
194    /// Get the cubemap path prefix (resolved)
195    /// Returns None if not configured, otherwise the resolved path prefix
196    pub fn shader_cubemap_path(&self) -> Option<PathBuf> {
197        self.shader
198            .custom_shader_cubemap
199            .as_ref()
200            .map(|p| Self::resolve_texture_path(p))
201    }
202
203    /// Get the user override config for a specific shader (if any)
204    pub fn get_shader_override(&self, shader_name: &str) -> Option<&ShaderConfig> {
205        self.shader_overrides.shader_configs.get(shader_name)
206    }
207
208    /// Get the user override config for a specific cursor shader (if any)
209    pub fn get_cursor_shader_override(&self, shader_name: &str) -> Option<&CursorShaderConfig> {
210        self.shader_overrides.cursor_shader_configs.get(shader_name)
211    }
212
213    /// Get or create a mutable reference to a shader's config override
214    pub fn get_or_create_shader_override(&mut self, shader_name: &str) -> &mut ShaderConfig {
215        self.shader_overrides
216            .shader_configs
217            .entry(shader_name.to_string())
218            .or_default()
219    }
220
221    /// Get or create a mutable reference to a cursor shader's config override
222    pub fn get_or_create_cursor_shader_override(
223        &mut self,
224        shader_name: &str,
225    ) -> &mut CursorShaderConfig {
226        self.shader_overrides
227            .cursor_shader_configs
228            .entry(shader_name.to_string())
229            .or_default()
230    }
231
232    /// Remove a shader config override (revert to defaults)
233    pub fn remove_shader_override(&mut self, shader_name: &str) {
234        self.shader_overrides.shader_configs.remove(shader_name);
235    }
236
237    /// Remove a cursor shader config override (revert to defaults)
238    pub fn remove_cursor_shader_override(&mut self, shader_name: &str) {
239        self.shader_overrides
240            .cursor_shader_configs
241            .remove(shader_name);
242    }
243
244    /// Check if the shaders folder is missing or empty
245    /// Returns true if user should be prompted to install shaders
246    pub fn should_prompt_shader_install(&self) -> bool {
247        // Only prompt if the preference is set to "ask"
248        if self.integrations.shader_install_prompt != ShaderInstallPrompt::Ask {
249            return false;
250        }
251
252        let shaders_dir = Self::shaders_dir();
253
254        // Check if directory doesn't exist
255        if !shaders_dir.exists() {
256            return true;
257        }
258
259        // Check if directory is empty or has no .glsl files
260        if let Ok(entries) = std::fs::read_dir(&shaders_dir) {
261            for entry in entries.flatten() {
262                if let Some(ext) = entry.path().extension()
263                    && ext == "glsl"
264                {
265                    return false; // Found at least one shader
266                }
267            }
268        }
269
270        true // Directory exists but has no .glsl files
271    }
272
273    /// Check if shaders should be prompted (version-aware logic)
274    ///
275    /// # Arguments
276    /// * `current_version` - The application version (from root crate's `VERSION` constant)
277    pub fn should_prompt_shader_install_versioned(&self, current_version: &str) -> bool {
278        if self.integrations.shader_install_prompt != ShaderInstallPrompt::Ask {
279            return false;
280        }
281
282        // Check if already prompted for this version
283        if let Some(ref prompted) = self
284            .integrations
285            .integration_versions
286            .shaders_prompted_version
287            && prompted == current_version
288        {
289            return false;
290        }
291
292        // Check if installed and up to date
293        if let Some(ref installed) = self
294            .integrations
295            .integration_versions
296            .shaders_installed_version
297            && installed == current_version
298        {
299            return false;
300        }
301
302        // Also check if shaders folder exists and has files
303        let shaders_dir = Self::shaders_dir();
304        !shaders_dir.exists() || !Self::has_shader_files(&shaders_dir)
305    }
306
307    /// Check if a directory contains shader files (.glsl)
308    fn has_shader_files(dir: &PathBuf) -> bool {
309        if let Ok(entries) = std::fs::read_dir(dir) {
310            for entry in entries.flatten() {
311                if let Some(ext) = entry.path().extension()
312                    && ext == "glsl"
313                {
314                    return true;
315                }
316            }
317        }
318        false
319    }
320}