Skip to main content

soar_utils/
path.rs

1use std::{
2    env,
3    path::{Component, Path, PathBuf},
4};
5
6use crate::{
7    error::{PathError, PathResult},
8    system::get_username,
9};
10
11/// Returns `true` if `name` is a single, normal path component that is safe to
12/// join onto a directory.
13///
14/// Rejects empty strings, absolute paths, `.`/`..`, and any value containing a
15/// path separator. Use this for untrusted values that become a path component,
16/// such as repository names or package `provides` entries, where a join must
17/// not escape its base directory.
18///
19/// # Example
20///
21/// ```
22/// use soar_utils::path::is_safe_component;
23///
24/// assert!(is_safe_component("soarpkgs"));
25/// assert!(!is_safe_component("../etc"));
26/// ```
27pub fn is_safe_component(name: &str) -> bool {
28    let mut components = Path::new(name).components();
29    matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
30}
31
32/// Resolves a path string that may contain environment variables
33///
34/// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde
35/// (`~`) to the user's home directory when it appears at the start of the path, and converts
36/// relative paths to absolute paths based on the current working directory.
37///
38/// # Arguments
39///
40/// * `path` - The path string that may contain environment variables and tilde expansion
41///
42/// # Returns
43///
44/// Returns an absolute [`PathBuf`] with all variables expanded, or a [`PathError`] if the path
45/// is invalid or variables cannot be resolved.
46///
47/// # Errors
48///
49/// * [`PathError::Empty`] if the path is empty
50/// * [`PathError::CurrentDir`] if the current directory cannot be determined
51/// * [`PathError::MissingEnvVar`] if the environment variables are undefined
52///
53/// # Example
54///
55/// ```
56/// use soar_utils::error::PathResult;
57/// use soar_utils::path::resolve_path;
58///
59/// fn main() -> PathResult<()> {
60///     let resolved = resolve_path("$HOME/path/to/file")?;
61///     println!("Resolved path is {:#?}", resolved);
62///     Ok(())
63/// }
64/// ```
65pub fn resolve_path(path: &str) -> PathResult<PathBuf> {
66    let path = path.trim();
67
68    if path.is_empty() {
69        return Err(PathError::Empty);
70    }
71
72    let resolved = expand_variables(path)?;
73    let path_buf = PathBuf::from(resolved);
74
75    if path_buf.is_absolute() {
76        Ok(path_buf)
77    } else {
78        env::current_dir()
79            .map(|cwd| cwd.join(path_buf))
80            .map_err(|err| {
81                PathError::FailedToGetCurrentDir {
82                    source: err,
83                }
84            })
85    }
86}
87
88/// Returns the user's home directory
89///
90/// This method first checks the `HOME` environment variables. If not set, it falls back to
91/// constructing the path `/home/{username}` where username is obtained from the system.
92///
93/// # Example
94///
95/// ```
96/// use soar_utils::path::home_dir;
97///
98/// let home = home_dir();
99/// println!("Home dir is {:#?}", home);
100/// ```
101pub fn home_dir() -> PathBuf {
102    env::var("HOME")
103        .map(PathBuf::from)
104        .unwrap_or_else(|_| PathBuf::from(format!("/home/{}", get_username())))
105}
106
107/// Returns the user's config directory following XDG Base Directory Specification
108///
109/// This method checks the `XDG_CONFIG_HOME` environment variable. If not set, it defaults to
110/// `$HOME/.config`
111///
112/// # Example
113///
114/// ```
115/// use soar_utils::path::xdg_config_home;
116///
117/// let config = xdg_config_home();
118/// println!("Config dir is {:#?}", config);
119/// ```
120pub fn xdg_config_home() -> PathBuf {
121    env::var("XDG_CONFIG_HOME")
122        .map(PathBuf::from)
123        .unwrap_or_else(|_| home_dir().join(".config"))
124}
125
126/// Returns the user's data directory following XDG Base Directory Specification
127///
128/// This method checks the `XDG_DATA_HOME` environment variable. If not set, it defaults to
129/// `$HOME/.local/share`
130///
131/// # Example
132///
133/// ```
134/// use soar_utils::path::xdg_data_home;
135///
136/// let data = xdg_data_home();
137/// println!("Data dir is {:#?}", data);
138/// ```
139pub fn xdg_data_home() -> PathBuf {
140    env::var("XDG_DATA_HOME")
141        .map(PathBuf::from)
142        .unwrap_or_else(|_| home_dir().join(".local/share"))
143}
144
145/// Returns the user's cache directory following XDG Base Directory Specification
146///
147/// This method checks the `XDG_CACHE_HOME` environment variable. If not set, it defaults to
148/// `$HOME/.cache`
149///
150/// # Example
151///
152/// ```
153/// use soar_utils::path::xdg_cache_home;
154///
155/// let cache = xdg_cache_home();
156/// println!("Cache dir is {:#?}", cache);
157/// ```
158pub fn xdg_cache_home() -> PathBuf {
159    env::var("XDG_CACHE_HOME")
160        .map(PathBuf::from)
161        .unwrap_or_else(|_| home_dir().join(".cache"))
162}
163
164/// Returns the desktop directory
165pub fn desktop_dir(system: bool) -> PathBuf {
166    if system {
167        PathBuf::from("/usr/local/share/applications")
168    } else {
169        xdg_data_home().join("applications")
170    }
171}
172
173/// Returns the icons directory
174pub fn icons_dir(system: bool) -> PathBuf {
175    if system {
176        PathBuf::from("/usr/local/share/icons/hicolor")
177    } else {
178        xdg_data_home().join("icons/hicolor")
179    }
180}
181
182fn expand_variables(path: &str) -> PathResult<String> {
183    let mut result = String::with_capacity(path.len());
184    let mut chars = path.chars().peekable();
185
186    while let Some(c) = chars.next() {
187        match c {
188            '$' => {
189                if chars.peek() == Some(&'{') {
190                    chars.next();
191                    let var_name = consume_until(&mut chars, '}')?;
192                    expand_env_var(&var_name, &mut result, path)?;
193                } else {
194                    let var_name = consume_var_name(&mut chars);
195                    if var_name.is_empty() {
196                        result.push('$');
197                    } else {
198                        expand_env_var(&var_name, &mut result, path)?;
199                    }
200                }
201            }
202            '~' if result.is_empty() => result.push_str(&home_dir().to_string_lossy()),
203            _ => result.push(c),
204        }
205    }
206
207    Ok(result)
208}
209
210fn consume_until(
211    chars: &mut std::iter::Peekable<std::str::Chars>,
212    delimiter: char,
213) -> PathResult<String> {
214    let mut var_name = String::new();
215
216    for c in chars.by_ref() {
217        if c == delimiter {
218            return Ok(var_name);
219        }
220        var_name.push(c);
221    }
222
223    Err(PathError::UnclosedVariable {
224        input: format!("${{{var_name}"),
225    })
226}
227
228fn consume_var_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
229    let mut var_name = String::new();
230
231    while let Some(&c) = chars.peek() {
232        if c.is_alphanumeric() || c == '_' {
233            var_name.push(chars.next().unwrap());
234        } else {
235            break;
236        }
237    }
238
239    var_name
240}
241
242fn expand_env_var(var_name: &str, result: &mut String, original: &str) -> PathResult<()> {
243    match var_name {
244        "HOME" => result.push_str(&home_dir().to_string_lossy()),
245        "XDG_CONFIG_HOME" => result.push_str(&xdg_config_home().to_string_lossy()),
246        "XDG_DATA_HOME" => result.push_str(&xdg_data_home().to_string_lossy()),
247        "XDG_CACHE_HOME" => result.push_str(&xdg_cache_home().to_string_lossy()),
248        _ => {
249            let value = env::var(var_name).map_err(|_| {
250                PathError::MissingEnvVar {
251                    input: original.into(),
252                    var: var_name.into(),
253                }
254            })?;
255            result.push_str(&value);
256        }
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263    #[test]
264    fn test_is_safe_component() {
265        assert!(super::is_safe_component("clipcat"));
266        assert!(super::is_safe_component("clip-cat.1"));
267        assert!(super::is_safe_component("soarpkgs"));
268
269        assert!(!super::is_safe_component(""));
270        assert!(!super::is_safe_component("."));
271        assert!(!super::is_safe_component(".."));
272        assert!(!super::is_safe_component("a/b"));
273        assert!(!super::is_safe_component("../etc"));
274        assert!(!super::is_safe_component("../../home/user/.bashrc"));
275        assert!(!super::is_safe_component("/etc/passwd"));
276    }
277
278    use std::env;
279
280    use serial_test::serial;
281
282    use super::*;
283
284    #[test]
285    fn test_expand_variables_simple() {
286        env::set_var("TEST_VAR", "test_value");
287
288        let result = expand_variables("$TEST_VAR/path").unwrap();
289        assert_eq!(result, "test_value/path");
290
291        env::remove_var("TEST_VAR");
292    }
293
294    #[test]
295    fn test_expand_variables_braces() {
296        env::set_var("TEST_VAR_BRACES", "test_value");
297
298        let result = expand_variables("${TEST_VAR_BRACES}/path").unwrap();
299        assert_eq!(result, "test_value/path");
300
301        env::remove_var("TEST_VAR_BRACES");
302    }
303
304    #[test]
305    fn test_expand_variables_missing_braces() {
306        env::set_var("TEST_VAR_MISSING_BRACES", "test_value");
307
308        let result = expand_variables("${TEST_VAR_MISSING_BRACES");
309        assert!(result.is_err());
310
311        env::remove_var("TEST_VAR_MISSING_BRACES");
312    }
313
314    #[test]
315    fn test_expand_variables_missing_var() {
316        let result = expand_variables("$THIS_VAR_DOESNT_EXIST");
317        assert!(result.is_err());
318    }
319
320    #[test]
321    fn test_consume_var_name() {
322        let mut chars = "VAR_NAME_123/extra".chars().peekable();
323        let var_name = consume_var_name(&mut chars);
324        assert_eq!(var_name, "VAR_NAME_123");
325    }
326
327    #[test]
328    #[serial]
329    fn test_xdg_directories() {
330        // We need to set HOME to have a predictable home directory for the test
331        env::set_var("HOME", "/tmp/home");
332        let home = home_dir();
333        assert_eq!(home, PathBuf::from("/tmp/home"));
334
335        // Test without XDG variables set
336        env::remove_var("XDG_CONFIG_HOME");
337        env::remove_var("XDG_DATA_HOME");
338        env::remove_var("XDG_CACHE_HOME");
339
340        let config = xdg_config_home();
341        let data = xdg_data_home();
342        let cache = xdg_cache_home();
343
344        assert_eq!(config, home.join(".config"));
345        assert_eq!(data, home.join(".local/share"));
346        assert_eq!(cache, home.join(".cache"));
347        assert!(config.is_absolute());
348        assert!(data.is_absolute());
349        assert!(cache.is_absolute());
350
351        // Test with XDG variables set
352        env::set_var("XDG_CONFIG_HOME", "/tmp/config");
353        env::set_var("XDG_DATA_HOME", "/tmp/data");
354        env::set_var("XDG_CACHE_HOME", "/tmp/cache");
355
356        assert_eq!(xdg_config_home(), PathBuf::from("/tmp/config"));
357        assert_eq!(xdg_data_home(), PathBuf::from("/tmp/data"));
358        assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/cache"));
359
360        env::remove_var("XDG_CONFIG_HOME");
361        env::remove_var("XDG_DATA_HOME");
362        env::remove_var("XDG_CACHE_HOME");
363        env::remove_var("HOME");
364    }
365
366    #[test]
367    #[serial]
368    fn test_resolve_path() {
369        env::set_var("HOME", "/tmp/home");
370
371        assert!(resolve_path("").is_err());
372
373        // Absolute path
374        assert_eq!(
375            resolve_path("/absolute/path").unwrap(),
376            PathBuf::from("/absolute/path")
377        );
378
379        // Relative path
380        let expected_relative = env::current_dir().unwrap().join("relative/path");
381        assert_eq!(resolve_path("relative/path").unwrap(), expected_relative);
382
383        // Tilde path
384        let home = home_dir();
385        assert_eq!(resolve_path("~/path").unwrap(), home.join("path"));
386        assert_eq!(resolve_path("~").unwrap(), home);
387
388        // Tilde not at start
389        let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start");
390        assert_eq!(
391            resolve_path("not/at/~/start").unwrap(),
392            expected_tilde_middle
393        );
394        env::remove_var("HOME");
395
396        // Unclosed variable
397        let result = resolve_path("${VAR");
398        assert!(result.is_err());
399
400        // Missing variable
401        let result = resolve_path("${VAR}");
402        assert!(result.is_err());
403    }
404
405    #[test]
406    #[serial]
407    fn test_home_dir() {
408        // Test with HOME set
409        env::set_var("HOME", "/tmp/home");
410        assert_eq!(home_dir(), PathBuf::from("/tmp/home"));
411
412        // Test with HOME unset
413        env::remove_var("HOME");
414        let expected = PathBuf::from(format!("/home/{}", get_username()));
415        assert_eq!(home_dir(), expected);
416    }
417
418    #[test]
419    #[serial]
420    fn test_expand_variables_edge_cases() {
421        env::set_var("HOME", "/tmp/home");
422
423        // Dollar at the end
424        assert_eq!(expand_variables("path/$").unwrap(), "path/$");
425
426        // Dollar with invalid char
427        assert_eq!(
428            expand_variables("path/$!invalid").unwrap(),
429            "path/$!invalid"
430        );
431
432        // Multiple variables
433        env::set_var("VAR1", "val1");
434        env::set_var("VAR2", "val2");
435        assert_eq!(expand_variables("$VAR1/${VAR2}").unwrap(), "val1/val2");
436        env::remove_var("VAR1");
437        env::remove_var("VAR2");
438
439        // Tilde expansion
440        let home_str = home_dir().to_string_lossy().to_string();
441        assert_eq!(
442            expand_variables("~/path").unwrap(),
443            format!("{}/path", home_str)
444        );
445        assert_eq!(expand_variables("~").unwrap(), home_str);
446        assert_eq!(expand_variables("a/~/b").unwrap(), "a/~/b");
447        env::remove_var("HOME");
448    }
449
450    #[test]
451    #[serial]
452    fn test_resolve_path_invalid_cwd() {
453        let temp_dir = tempfile::tempdir().unwrap();
454        let invalid_path = temp_dir.path().join("invalid");
455        std::fs::create_dir(&invalid_path).unwrap();
456
457        let original_cwd = env::current_dir().unwrap();
458        env::set_current_dir(&invalid_path).unwrap();
459        std::fs::remove_dir(&invalid_path).unwrap();
460
461        let result = resolve_path("relative/path");
462        assert!(result.is_err());
463
464        // Restore cwd
465        env::set_current_dir(original_cwd).unwrap();
466    }
467
468    #[test]
469    #[serial]
470    fn test_expand_env_var_special_vars() {
471        env::set_var("HOME", "/tmp/home");
472        env::remove_var("XDG_CONFIG_HOME");
473        env::remove_var("XDG_DATA_HOME");
474        env::remove_var("XDG_CACHE_HOME");
475
476        let mut result = String::new();
477        expand_env_var("HOME", &mut result, "$HOME").unwrap();
478        assert_eq!(result, "/tmp/home");
479
480        result.clear();
481        expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME").unwrap();
482        assert_eq!(result, "/tmp/home/.config");
483
484        result.clear();
485        expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME").unwrap();
486        assert_eq!(result, "/tmp/home/.local/share");
487
488        result.clear();
489        expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME").unwrap();
490        assert_eq!(result, "/tmp/home/.cache");
491
492        env::remove_var("HOME");
493    }
494
495    #[test]
496    #[serial]
497    fn test_desktop_dir() {
498        // User mode
499        env::set_var("XDG_DATA_HOME", "/tmp/data");
500        let desktop = desktop_dir(false);
501        assert_eq!(desktop, PathBuf::from("/tmp/data/applications"));
502
503        // System mode
504        let desktop = desktop_dir(true);
505        assert_eq!(desktop, PathBuf::from("/usr/local/share/applications"));
506    }
507
508    #[test]
509    #[serial]
510    fn test_icons_dir() {
511        // User mode
512        env::set_var("XDG_DATA_HOME", "/tmp/data");
513        let icons = icons_dir(false);
514        assert_eq!(icons, PathBuf::from("/tmp/data/icons/hicolor"));
515
516        // System mode
517        let icons = icons_dir(true);
518        assert_eq!(icons, PathBuf::from("/usr/local/share/icons/hicolor"));
519    }
520}