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}