Skip to main content

path_rs/
expand.rs

1//! User path input expansion: `~`, `%VAR%`, `$VAR`, `${VAR}`, and optional WSL translation.
2//!
3//! Expansion does **not** invoke a shell, interpret command substitution, or execute commands.
4//! It never expands `$(...)` or backticks.
5
6use crate::error::PathError;
7use crate::internal::validation::{MAX_EXPANSION_DEPTH, reject_nul};
8use crate::platform::translate_wsl_path;
9use std::env;
10use std::path::PathBuf;
11
12/// Options controlling how user path input is expanded.
13///
14/// # Defaults
15///
16/// - Expand `~` at the start of the path
17/// - Expand `%VAR%` and `$VAR` / `${VAR}`
18/// - Reject undefined variables
19/// - Trim surrounding whitespace
20/// - Do **not** translate WSL `/mnt/<drive>/...` paths
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ExpandOptions {
23    /// Expand a leading `~` or `~/` / `~\` to the home directory.
24    pub expand_tilde: bool,
25    /// Expand `%VAR%` style variables (generic; works on any platform if set).
26    pub expand_percent_variables: bool,
27    /// Expand `$VAR` and `${VAR}` style variables.
28    pub expand_dollar_variables: bool,
29    /// Translate `/mnt/<drive>/...` to a Windows path when applicable.
30    pub translate_wsl_paths: bool,
31    /// When `true`, undefined variables produce an error; when `false`, they are left unchanged.
32    pub reject_undefined_variables: bool,
33    /// Trim leading and trailing whitespace from the input.
34    pub trim_cli_input: bool,
35    /// Maximum nested expansion passes (defensive limit).
36    pub max_expansion_depth: u32,
37}
38
39impl Default for ExpandOptions {
40    fn default() -> Self {
41        Self {
42            expand_tilde: true,
43            expand_percent_variables: true,
44            expand_dollar_variables: true,
45            translate_wsl_paths: false,
46            reject_undefined_variables: true,
47            trim_cli_input: true,
48            max_expansion_depth: MAX_EXPANSION_DEPTH,
49        }
50    }
51}
52
53impl ExpandOptions {
54    /// Create default options (`Self::default()`).
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Create options with all expansion disabled.
60    pub fn none() -> Self {
61        Self {
62            expand_tilde: false,
63            expand_percent_variables: false,
64            expand_dollar_variables: false,
65            translate_wsl_paths: false,
66            reject_undefined_variables: true,
67            trim_cli_input: false,
68            max_expansion_depth: MAX_EXPANSION_DEPTH,
69        }
70    }
71}
72
73/// Expand user path input according to `options`.
74///
75/// Pipeline:
76/// 1. Optional trim
77/// 2. Optional WSL translation (before other expansion when enabled)
78/// 3. Optional tilde expansion
79/// 4. Optional percent and dollar variable expansion (with depth limit)
80///
81/// # Filesystem access
82///
83/// Home directory resolution may consult environment variables / platform APIs
84/// via the `dirs` crate. No other filesystem I/O is performed.
85///
86/// # Security
87///
88/// Expanded environment values are untrusted input. Do not treat expansion as
89/// path validation or containment.
90pub fn expand_input(input: &str, options: &ExpandOptions) -> Result<PathBuf, PathError> {
91    let mut s = if options.trim_cli_input {
92        input.trim().to_string()
93    } else {
94        input.to_string()
95    };
96
97    reject_nul(&s)?;
98
99    if s.is_empty() {
100        return Err(PathError::EmptyInput);
101    }
102
103    if options.translate_wsl_paths {
104        if let Some(translated) = translate_wsl_path(&s)? {
105            return Ok(translated);
106        }
107    }
108
109    if options.expand_tilde {
110        s = expand_tilde(&s)?;
111    }
112
113    // Multi-pass expansion with a hard depth limit (DoS protection).
114    // Each pass expands percent then dollar forms once; stop when stable.
115    for _ in 0..options.max_expansion_depth {
116        let prev = s.clone();
117        if options.expand_percent_variables {
118            s = expand_percent_variables(&s, options.reject_undefined_variables)?;
119        }
120        if options.expand_dollar_variables {
121            s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
122        }
123        if s == prev {
124            reject_nul(&s)?;
125            return Ok(PathBuf::from(s));
126        }
127    }
128
129    // Budget exhausted: error only if another pass would still change the value.
130    let prev = s.clone();
131    if options.expand_percent_variables {
132        s = expand_percent_variables(&s, options.reject_undefined_variables)?;
133    }
134    if options.expand_dollar_variables {
135        s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
136    }
137    if s != prev {
138        return Err(PathError::ExpansionDepthExceeded {
139            max_depth: options.max_expansion_depth,
140        });
141    }
142
143    reject_nul(&s)?;
144    Ok(PathBuf::from(s))
145}
146
147/// Expand a leading `~` component to the home directory.
148///
149/// Only expands when `~` is the entire path or the first component (`~/...`, `~\...`).
150/// Does not expand `~user` or mid-path `~/`.
151///
152/// # Filesystem access
153///
154/// Resolves the home directory via platform APIs / environment.
155pub fn expand_tilde(input: &str) -> Result<String, PathError> {
156    reject_nul(input)?;
157
158    if input == "~" {
159        return home_string();
160    }
161
162    // ~/ or ~\ only
163    let bytes = input.as_bytes();
164    if bytes.first() == Some(&b'~') && bytes.get(1).is_some_and(|b| *b == b'/' || *b == b'\\') {
165        let home = home_string()?;
166        let mut out = home;
167        // Keep the separator from the input for readability; PathBuf will normalize later.
168        out.push_str(&input[1..]);
169        return Ok(out);
170    }
171
172    Ok(input.to_string())
173}
174
175/// Expand `%VAR%` style environment variables.
176///
177/// - `%%` becomes a literal `%`
178/// - Incomplete forms like `%APPDATA` or bare `%` error when `reject_undefined` is true;
179///   when false, incomplete forms are left as-is where possible
180/// - Undefined variables error when `reject_undefined` is true; otherwise left unchanged
181///
182/// Does not recursively re-scan beyond a single left-to-right pass. Callers that need
183/// multi-pass expansion should use [`expand_input`].
184pub fn expand_percent_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
185    reject_nul(input)?;
186    let chars: Vec<char> = input.chars().collect();
187    let mut out = String::with_capacity(input.len());
188    let mut i = 0usize;
189
190    while i < chars.len() {
191        if chars[i] != '%' {
192            out.push(chars[i]);
193            i += 1;
194            continue;
195        }
196
197        // %% -> literal %
198        if i + 1 < chars.len() && chars[i + 1] == '%' {
199            out.push('%');
200            i += 2;
201            continue;
202        }
203
204        // Find closing %
205        let start = i + 1;
206        let mut end = start;
207        while end < chars.len() && chars[end] != '%' {
208            end += 1;
209        }
210
211        if end >= chars.len() {
212            // Unclosed %VAR
213            let fragment: String = chars[i..].iter().collect();
214            if reject_undefined {
215                return Err(PathError::MalformedEnvironmentVariable { input: fragment });
216            }
217            out.push_str(&fragment);
218            break;
219        }
220
221        if end == start {
222            // %% already handled; empty %% name means adjacent % after non-escape path.
223            // Lone empty name %% handled above. Here `%%` at i would have been caught.
224            // `% %` empty between: treat as malformed / empty var name.
225            if reject_undefined {
226                return Err(PathError::MalformedEnvironmentVariable { input: "%%".into() });
227            }
228            out.push('%');
229            i = end + 1;
230            continue;
231        }
232
233        let name: String = chars[start..end].iter().collect();
234        if !is_valid_env_name(&name) {
235            if reject_undefined {
236                return Err(PathError::MalformedEnvironmentVariable {
237                    input: format!("%{name}%"),
238                });
239            }
240            out.push('%');
241            out.push_str(&name);
242            out.push('%');
243            i = end + 1;
244            continue;
245        }
246
247        match env::var(&name) {
248            Ok(value) => out.push_str(&value),
249            Err(env::VarError::NotPresent) => {
250                if reject_undefined {
251                    return Err(PathError::UndefinedEnvironmentVariable { name });
252                }
253                // Permissive: leave the original token unchanged.
254                out.push('%');
255                out.push_str(&name);
256                out.push('%');
257            }
258            Err(env::VarError::NotUnicode(_)) => {
259                return Err(PathError::invalid(format!(
260                    "environment variable {name} is not valid Unicode"
261                )));
262            }
263        }
264        i = end + 1;
265    }
266
267    Ok(out)
268}
269
270/// Expand `$VAR` and `${VAR}` style environment variables.
271///
272/// Rules:
273/// - `${VAR}` requires a closing `}`
274/// - `$VAR` matches `[A-Za-z_][A-Za-z0-9_]*`
275/// - `$123` is not a variable (digit-leading names are not expanded)
276/// - `$(` is never expanded (command substitution is unsupported)
277/// - Backticks are never expanded
278///
279/// Undefined variables: error when `reject_undefined` is true; otherwise leave the token.
280pub fn expand_dollar_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
281    reject_nul(input)?;
282    let chars: Vec<char> = input.chars().collect();
283    let mut out = String::with_capacity(input.len());
284    let mut i = 0usize;
285
286    while i < chars.len() {
287        if chars[i] != '$' {
288            out.push(chars[i]);
289            i += 1;
290            continue;
291        }
292
293        // $$ -> literal $
294        if i + 1 < chars.len() && chars[i + 1] == '$' {
295            out.push('$');
296            i += 2;
297            continue;
298        }
299
300        if i + 1 >= chars.len() {
301            if reject_undefined {
302                return Err(PathError::MalformedEnvironmentVariable { input: "$".into() });
303            }
304            out.push('$');
305            break;
306        }
307
308        // Never expand command substitution.
309        if chars[i + 1] == '(' {
310            out.push('$');
311            i += 1;
312            continue;
313        }
314
315        // ${VAR}
316        if chars[i + 1] == '{' {
317            let start = i + 2;
318            let mut end = start;
319            while end < chars.len() && chars[end] != '}' {
320                end += 1;
321            }
322            if end >= chars.len() {
323                let fragment: String = chars[i..].iter().collect();
324                if reject_undefined {
325                    return Err(PathError::MalformedEnvironmentVariable { input: fragment });
326                }
327                out.push_str(&fragment);
328                break;
329            }
330            let name: String = chars[start..end].iter().collect();
331            if name.is_empty() || !is_valid_env_name(&name) {
332                if reject_undefined {
333                    return Err(PathError::MalformedEnvironmentVariable {
334                        input: format!("${{{name}}}"),
335                    });
336                }
337                out.push_str(&format!("${{{name}}}"));
338                i = end + 1;
339                continue;
340            }
341            match env::var(&name) {
342                Ok(value) => out.push_str(&value),
343                Err(env::VarError::NotPresent) => {
344                    if reject_undefined {
345                        return Err(PathError::UndefinedEnvironmentVariable { name });
346                    }
347                    out.push_str(&format!("${{{name}}}"));
348                }
349                Err(env::VarError::NotUnicode(_)) => {
350                    return Err(PathError::invalid(format!(
351                        "environment variable {name} is not valid Unicode"
352                    )));
353                }
354            }
355            i = end + 1;
356            continue;
357        }
358
359        // $VAR
360        if is_env_name_start(chars[i + 1]) {
361            let start = i + 1;
362            let mut end = start + 1;
363            while end < chars.len() && is_env_name_continue(chars[end]) {
364                end += 1;
365            }
366            let name: String = chars[start..end].iter().collect();
367            match env::var(&name) {
368                Ok(value) => out.push_str(&value),
369                Err(env::VarError::NotPresent) => {
370                    if reject_undefined {
371                        return Err(PathError::UndefinedEnvironmentVariable { name });
372                    }
373                    out.push('$');
374                    out.push_str(&name);
375                }
376                Err(env::VarError::NotUnicode(_)) => {
377                    return Err(PathError::invalid(format!(
378                        "environment variable {name} is not valid Unicode"
379                    )));
380                }
381            }
382            i = end;
383            continue;
384        }
385
386        // `$` followed by something that is not a name (e.g. `$123`, `$}`).
387        out.push('$');
388        i += 1;
389    }
390
391    Ok(out)
392}
393
394fn home_string() -> Result<String, PathError> {
395    let home = dirs::home_dir().ok_or(PathError::HomeDirectoryUnavailable)?;
396    home.into_os_string()
397        .into_string()
398        .map_err(|_| PathError::NotUtf8)
399}
400
401fn is_valid_env_name(name: &str) -> bool {
402    let mut chars = name.chars();
403    match chars.next() {
404        Some(c) if is_env_name_start(c) => chars.all(is_env_name_continue),
405        _ => false,
406    }
407}
408
409fn is_env_name_start(c: char) -> bool {
410    c.is_ascii_alphabetic() || c == '_'
411}
412
413fn is_env_name_continue(c: char) -> bool {
414    c.is_ascii_alphanumeric() || c == '_'
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn tilde_only_at_start() {
423        // Without a real home we only check that mid-path is unchanged.
424        let s = expand_tilde("foo/~/bar").unwrap();
425        assert_eq!(s, "foo/~/bar");
426        let s = expand_tilde("~other/foo").unwrap();
427        assert_eq!(s, "~other/foo");
428    }
429
430    #[test]
431    fn percent_escape() {
432        let s = expand_percent_variables("100%% done", true).unwrap();
433        assert_eq!(s, "100% done");
434    }
435
436    #[test]
437    fn dollar_no_command_sub() {
438        let s = expand_dollar_variables("$(whoami)/x", true).unwrap();
439        assert_eq!(s, "$(whoami)/x");
440    }
441
442    #[test]
443    fn dollar_digit_not_var() {
444        let s = expand_dollar_variables("$123", true).unwrap();
445        assert_eq!(s, "$123");
446    }
447
448    #[test]
449    fn unclosed_percent_strict() {
450        assert!(expand_percent_variables("%APPDATA", true).is_err());
451        assert!(expand_percent_variables("%", true).is_err());
452    }
453
454    #[test]
455    fn unclosed_dollar_strict() {
456        assert!(expand_dollar_variables("${HOME", true).is_err());
457    }
458}