Skip to main content

krypt_core/paths/
resolve.rs

1//! `${...}` expansion engine.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5use thiserror::Error;
6
7use super::platform::Platform;
8
9/// Things that can go wrong while resolving a path expression.
10#[derive(Debug, Error, PartialEq, Eq)]
11pub enum ResolveError {
12    /// The expression couldn't be tokenized (unclosed `${`, empty `${}`, ...).
13    #[error("malformed path expression `{input}`: {reason}")]
14    Malformed {
15        /// The exact expression that failed.
16        input: String,
17        /// What went wrong.
18        reason: String,
19    },
20
21    /// `${NAME}` referenced a var we don't know about.
22    #[error("unknown path variable `${{{name}}}`")]
23    UnknownVar {
24        /// Name of the missing var.
25        name: String,
26    },
27
28    /// Var resolution looped back on itself.
29    #[error("cycle resolving `${{{name}}}`: {chain}")]
30    Cycle {
31        /// Var that started the cycle.
32        name: String,
33        /// `a -> b -> c -> a` style chain for the error message.
34        chain: String,
35    },
36
37    /// `${WIN_*}` used outside Windows or `${MAC_*}` outside macOS.
38    #[error("`${{{name}}}` is only available on {required} (current platform: {current})")]
39    WrongPlatform {
40        /// Var that's platform-gated.
41        name: String,
42        /// Platform that var is available on.
43        required: Platform,
44        /// Platform we're actually running on.
45        current: Platform,
46    },
47}
48
49/// Resolver for `${...}` expressions.
50///
51/// Build with [`Resolver::new`] for production use; the [`Resolver::for_platform`]
52/// and `with_*` builders are for tests and explicit overrides.
53#[derive(Debug, Clone)]
54pub struct Resolver {
55    platform: Platform,
56    overrides: BTreeMap<String, String>,
57    env: HashMap<String, String>,
58}
59
60impl Resolver {
61    /// Auto-detect platform and snapshot the current process env.
62    pub fn new() -> Self {
63        Self {
64            platform: Platform::current(),
65            overrides: BTreeMap::new(),
66            env: std::env::vars().collect(),
67        }
68    }
69
70    /// Construct with an explicit platform. Useful in tests.
71    pub fn for_platform(platform: Platform) -> Self {
72        Self {
73            platform,
74            overrides: BTreeMap::new(),
75            env: std::env::vars().collect(),
76        }
77    }
78
79    /// Replace the env snapshot used for `${env:...}` lookups. Useful in
80    /// tests to avoid leaking the host's environment in.
81    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
82        self.env = env;
83        self
84    }
85
86    /// Layer `[paths]`-style overrides on top of the built-in defaults.
87    /// Override values may themselves contain `${...}` expressions.
88    pub fn with_overrides(mut self, overrides: BTreeMap<String, String>) -> Self {
89        self.overrides = overrides;
90        self
91    }
92
93    /// Resolve a string containing zero or more `${...}` expressions.
94    pub fn resolve(&self, input: &str) -> Result<String, ResolveError> {
95        self.resolve_with_stack(input, &mut Vec::new())
96    }
97
98    /// Resolve a single bare variable name (no `${}` syntax) to its value.
99    /// `${NAME}` in a template eventually calls this.
100    pub fn resolve_var(&self, name: &str) -> Result<String, ResolveError> {
101        self.resolve_var_with_stack(name, &mut Vec::new())
102    }
103
104    /// Iterate over every variable name the resolver knows how to expand —
105    /// built-ins available on the current platform, plus all overrides.
106    /// Useful for `krypt paths` and `krypt doctor`.
107    pub fn known_vars(&self) -> Vec<String> {
108        let mut names: HashSet<String> = self
109            .builtin_names()
110            .iter()
111            .map(|s| (*s).to_string())
112            .collect();
113        names.extend(self.overrides.keys().cloned());
114        let mut out: Vec<String> = names.into_iter().collect();
115        out.sort();
116        out
117    }
118
119    /// Names of built-in vars defined on the current platform.
120    fn builtin_names(&self) -> &'static [&'static str] {
121        match self.platform {
122            Platform::Windows => &[
123                "HOME",
124                "XDG_CONFIG",
125                "XDG_DATA",
126                "XDG_STATE",
127                "XDG_CACHE",
128                "XDG_RUNTIME",
129                "LOCAL_BIN",
130                "DOCUMENTS",
131                "WIN_LOCALAPPDATA",
132                "WIN_APPDATA",
133            ],
134            Platform::Macos => &[
135                "HOME",
136                "XDG_CONFIG",
137                "XDG_DATA",
138                "XDG_STATE",
139                "XDG_CACHE",
140                "XDG_RUNTIME",
141                "LOCAL_BIN",
142                "DOCUMENTS",
143                "MAC_LIBRARY",
144            ],
145            Platform::Linux => &[
146                "HOME",
147                "XDG_CONFIG",
148                "XDG_DATA",
149                "XDG_STATE",
150                "XDG_CACHE",
151                "XDG_RUNTIME",
152                "LOCAL_BIN",
153                "DOCUMENTS",
154            ],
155        }
156    }
157
158    // ─── Internals ─────────────────────────────────────────────────────
159
160    fn resolve_with_stack(
161        &self,
162        input: &str,
163        stack: &mut Vec<String>,
164    ) -> Result<String, ResolveError> {
165        let mut out = String::with_capacity(input.len());
166        let mut rest = input;
167        while let Some(idx) = rest.find("${") {
168            out.push_str(&rest[..idx]);
169            rest = &rest[idx + 2..]; // skip `${`
170            // Find the matching `}` accounting for nested `${...}` (needed
171            // for `${env:VAR:-${HOME}/sub}` patterns).
172            let end = find_matching_brace(rest).ok_or_else(|| ResolveError::Malformed {
173                input: input.into(),
174                reason: "unclosed `${`".into(),
175            })?;
176            let expr = &rest[..end];
177            if expr.is_empty() {
178                return Err(ResolveError::Malformed {
179                    input: input.into(),
180                    reason: "empty `${}`".into(),
181                });
182            }
183            let resolved = self.resolve_expr(expr, stack)?;
184            out.push_str(&resolved);
185            rest = &rest[end + 1..]; // skip `}`
186        }
187        out.push_str(rest);
188        Ok(out)
189    }
190
191    /// One `${...}` body: either `NAME`, `env:NAME`, or `env:NAME:-fallback`.
192    fn resolve_expr(&self, expr: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
193        if let Some(env_expr) = expr.strip_prefix("env:") {
194            let (name, fallback) = match env_expr.split_once(":-") {
195                Some((n, fb)) => (n, Some(fb)),
196                None => (env_expr, None),
197            };
198            if name.is_empty() {
199                return Err(ResolveError::Malformed {
200                    input: format!("${{{expr}}}"),
201                    reason: "empty env var name".into(),
202                });
203            }
204            return match self.env.get(name) {
205                Some(v) if !v.is_empty() => Ok(v.clone()),
206                _ => match fallback {
207                    Some(fb) => self.resolve_with_stack(fb, stack),
208                    None => Ok(String::new()),
209                },
210            };
211        }
212        self.resolve_var_with_stack(expr, stack)
213    }
214
215    fn resolve_var_with_stack(
216        &self,
217        name: &str,
218        stack: &mut Vec<String>,
219    ) -> Result<String, ResolveError> {
220        if stack.iter().any(|n| n == name) {
221            let chain = stack
222                .iter()
223                .chain(std::iter::once(&name.to_string()))
224                .cloned()
225                .collect::<Vec<_>>()
226                .join(" -> ");
227            return Err(ResolveError::Cycle {
228                name: name.into(),
229                chain,
230            });
231        }
232        stack.push(name.into());
233        let result = self.lookup_var(name, stack);
234        stack.pop();
235        result
236    }
237
238    fn lookup_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
239        if let Some(template) = self.overrides.get(name) {
240            return self.resolve_with_stack(template, stack);
241        }
242        self.builtin_var(name, stack)
243    }
244
245    fn builtin_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
246        // Platform-gated first — error helpfully if used on the wrong OS.
247        match name {
248            "WIN_LOCALAPPDATA" | "WIN_APPDATA" if self.platform != Platform::Windows => {
249                return Err(ResolveError::WrongPlatform {
250                    name: name.into(),
251                    required: Platform::Windows,
252                    current: self.platform,
253                });
254            }
255            "MAC_LIBRARY" if self.platform != Platform::Macos => {
256                return Err(ResolveError::WrongPlatform {
257                    name: name.into(),
258                    required: Platform::Macos,
259                    current: self.platform,
260                });
261            }
262            _ => {}
263        }
264
265        // HOME is the root of every other built-in. Read directly from the
266        // platform-appropriate env var. Override-driven HOME goes through
267        // `lookup_var` upstream, so this branch only runs when there's no
268        // override and we genuinely need the env value.
269        let home_env_key = match self.platform {
270            Platform::Windows => "USERPROFILE",
271            Platform::Linux | Platform::Macos => "HOME",
272        };
273        let home_from_env = || -> Result<String, ResolveError> {
274            self.env
275                .get(home_env_key)
276                .filter(|v| !v.is_empty())
277                .cloned()
278                .ok_or_else(|| ResolveError::UnknownVar { name: name.into() })
279        };
280        // For derived vars (XDG_*, LOCAL_BIN, DOCUMENTS, MAC_LIBRARY) we
281        // re-enter the resolver so that overriding `HOME` also reroutes
282        // everything that derives from it.
283        let derived_from_home =
284            |suffix: &str, stack: &mut Vec<String>| -> Result<String, ResolveError> {
285                let h = self.resolve_var_with_stack("HOME", stack)?;
286                Ok(format!("{h}{suffix}"))
287            };
288        let xdg = |env_key: &str,
289                   fallback_suffix: &str,
290                   stack: &mut Vec<String>|
291         -> Result<String, ResolveError> {
292            if let Some(v) = self.env.get(env_key)
293                && !v.is_empty()
294            {
295                return Ok(v.clone());
296            }
297            derived_from_home(fallback_suffix, stack)
298        };
299
300        match name {
301            "HOME" => home_from_env(),
302            "XDG_CONFIG" => xdg("XDG_CONFIG_HOME", "/.config", stack),
303            "XDG_DATA" => xdg("XDG_DATA_HOME", "/.local/share", stack),
304            "XDG_STATE" => xdg("XDG_STATE_HOME", "/.local/state", stack),
305            "XDG_CACHE" => xdg("XDG_CACHE_HOME", "/.cache", stack),
306            "XDG_RUNTIME" => {
307                if let Some(v) = self.env.get("XDG_RUNTIME_DIR")
308                    && !v.is_empty()
309                {
310                    return Ok(v.clone());
311                }
312                let fallback_keys = match self.platform {
313                    Platform::Windows => ["TEMP", "TMP"].as_slice(),
314                    Platform::Linux | Platform::Macos => ["TMPDIR"].as_slice(),
315                };
316                for k in fallback_keys {
317                    if let Some(v) = self.env.get(*k)
318                        && !v.is_empty()
319                    {
320                        return Ok(v.clone());
321                    }
322                }
323                Ok(match self.platform {
324                    Platform::Windows => "C:/Windows/Temp".into(),
325                    _ => "/tmp".into(),
326                })
327            }
328            "LOCAL_BIN" => derived_from_home("/.local/bin", stack),
329            "DOCUMENTS" => derived_from_home("/Documents", stack),
330            "WIN_LOCALAPPDATA" => self
331                .env
332                .get("LOCALAPPDATA")
333                .filter(|v| !v.is_empty())
334                .cloned()
335                .ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
336            "WIN_APPDATA" => self
337                .env
338                .get("APPDATA")
339                .filter(|v| !v.is_empty())
340                .cloned()
341                .ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
342            "MAC_LIBRARY" => derived_from_home("/Library", stack),
343            _ => Err(ResolveError::UnknownVar { name: name.into() }),
344        }
345    }
346}
347
348impl Default for Resolver {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354/// Find the byte index of the `}` that matches the opening `${` we just
355/// consumed, treating nested `${...}` as opaque pairs. Returns `None` if
356/// no matching close brace exists.
357fn find_matching_brace(s: &str) -> Option<usize> {
358    let bytes = s.as_bytes();
359    let mut depth: usize = 0;
360    let mut i = 0;
361    while i < bytes.len() {
362        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
363            depth += 1;
364            i += 2;
365            continue;
366        }
367        if bytes[i] == b'}' {
368            if depth == 0 {
369                return Some(i);
370            }
371            depth -= 1;
372        }
373        i += 1;
374    }
375    None
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn linux(home: &str) -> Resolver {
383        let mut env = HashMap::new();
384        env.insert("HOME".into(), home.into());
385        Resolver::for_platform(Platform::Linux).with_env(env)
386    }
387
388    fn windows(profile: &str) -> Resolver {
389        let mut env = HashMap::new();
390        env.insert("USERPROFILE".into(), profile.into());
391        env.insert("LOCALAPPDATA".into(), format!("{profile}/AppData/Local"));
392        env.insert("APPDATA".into(), format!("{profile}/AppData/Roaming"));
393        Resolver::for_platform(Platform::Windows).with_env(env)
394    }
395
396    fn macos(home: &str) -> Resolver {
397        let mut env = HashMap::new();
398        env.insert("HOME".into(), home.into());
399        Resolver::for_platform(Platform::Macos).with_env(env)
400    }
401
402    #[test]
403    fn home_resolves() {
404        let r = linux("/home/user");
405        assert_eq!(r.resolve("${HOME}/x").unwrap(), "/home/user/x");
406    }
407
408    #[test]
409    fn xdg_falls_back_to_default() {
410        let r = linux("/home/user");
411        assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/home/user/.config");
412        assert_eq!(r.resolve("${XDG_DATA}").unwrap(), "/home/user/.local/share");
413    }
414
415    #[test]
416    fn xdg_env_override_wins() {
417        let mut env = HashMap::new();
418        env.insert("HOME".into(), "/home/user".into());
419        env.insert("XDG_CONFIG_HOME".into(), "/custom/cfg".into());
420        let r = Resolver::for_platform(Platform::Linux).with_env(env);
421        assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/cfg");
422    }
423
424    #[test]
425    fn env_passthrough() {
426        let mut env = HashMap::new();
427        env.insert("HOME".into(), "/h".into());
428        env.insert("EDITOR".into(), "nvim".into());
429        let r = Resolver::for_platform(Platform::Linux).with_env(env);
430        assert_eq!(r.resolve("editor=${env:EDITOR}").unwrap(), "editor=nvim");
431    }
432
433    #[test]
434    fn env_fallback_used_when_unset() {
435        let r = linux("/h");
436        assert_eq!(r.resolve("${env:NOPE:-default}").unwrap(), "default");
437    }
438
439    #[test]
440    fn env_fallback_can_reference_other_vars() {
441        let r = linux("/h");
442        assert_eq!(r.resolve("${env:NOPE:-${HOME}/x}").unwrap(), "/h/x");
443    }
444
445    #[test]
446    fn user_override_shadows_builtin() {
447        let mut overrides = BTreeMap::new();
448        overrides.insert("HOME".into(), "/custom".into());
449        let r = linux("/orig").with_overrides(overrides);
450        assert_eq!(r.resolve("${HOME}/x").unwrap(), "/custom/x");
451    }
452
453    #[test]
454    fn home_override_cascades_into_derived_vars() {
455        let mut overrides = BTreeMap::new();
456        overrides.insert("HOME".into(), "/custom".into());
457        let r = linux("/orig").with_overrides(overrides);
458        assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/.config");
459        assert_eq!(r.resolve("${LOCAL_BIN}").unwrap(), "/custom/.local/bin");
460        assert_eq!(r.resolve("${DOCUMENTS}").unwrap(), "/custom/Documents");
461    }
462
463    #[test]
464    fn override_loop_through_derived_vars_is_caught() {
465        let mut overrides = BTreeMap::new();
466        // HOME -> XDG_CONFIG -> HOME (via derived_from_home)
467        overrides.insert("HOME".into(), "${XDG_CONFIG}/parent".into());
468        let r = linux("/orig").with_overrides(overrides);
469        assert!(matches!(
470            r.resolve("${HOME}").unwrap_err(),
471            ResolveError::Cycle { .. }
472        ));
473    }
474
475    #[test]
476    fn override_can_reference_other_vars() {
477        let mut overrides = BTreeMap::new();
478        overrides.insert("MYBIN".into(), "${HOME}/bin".into());
479        let r = linux("/h").with_overrides(overrides);
480        assert_eq!(r.resolve("${MYBIN}/x").unwrap(), "/h/bin/x");
481    }
482
483    #[test]
484    fn cycle_is_detected() {
485        let mut overrides = BTreeMap::new();
486        overrides.insert("A".into(), "${B}".into());
487        overrides.insert("B".into(), "${A}".into());
488        let r = linux("/h").with_overrides(overrides);
489        match r.resolve("${A}").unwrap_err() {
490            ResolveError::Cycle { chain, .. } => assert!(chain.contains("A -> B -> A")),
491            other => panic!("expected Cycle, got {other:?}"),
492        }
493    }
494
495    #[test]
496    fn unknown_var_errors() {
497        let r = linux("/h");
498        match r.resolve("${NOPE}").unwrap_err() {
499            ResolveError::UnknownVar { name } => assert_eq!(name, "NOPE"),
500            other => panic!("expected UnknownVar, got {other:?}"),
501        }
502    }
503
504    #[test]
505    fn win_var_errors_on_linux() {
506        let r = linux("/h");
507        let err = r.resolve("${WIN_LOCALAPPDATA}").unwrap_err();
508        assert!(matches!(err, ResolveError::WrongPlatform { .. }));
509    }
510
511    #[test]
512    fn win_var_resolves_on_windows() {
513        let r = windows("C:/Users/u");
514        assert_eq!(
515            r.resolve("${WIN_LOCALAPPDATA}/x").unwrap(),
516            "C:/Users/u/AppData/Local/x"
517        );
518        assert_eq!(
519            r.resolve("${WIN_APPDATA}/x").unwrap(),
520            "C:/Users/u/AppData/Roaming/x"
521        );
522    }
523
524    #[test]
525    fn mac_var_errors_on_linux() {
526        let r = linux("/h");
527        assert!(matches!(
528            r.resolve("${MAC_LIBRARY}").unwrap_err(),
529            ResolveError::WrongPlatform { .. }
530        ));
531    }
532
533    #[test]
534    fn mac_var_resolves_on_macos() {
535        let r = macos("/Users/u");
536        assert_eq!(r.resolve("${MAC_LIBRARY}/x").unwrap(), "/Users/u/Library/x");
537    }
538
539    #[test]
540    fn unclosed_brace_errors() {
541        let r = linux("/h");
542        let err = r.resolve("${HOME").unwrap_err();
543        assert!(matches!(err, ResolveError::Malformed { .. }));
544    }
545
546    #[test]
547    fn empty_braces_errors() {
548        let r = linux("/h");
549        let err = r.resolve("${}").unwrap_err();
550        assert!(matches!(err, ResolveError::Malformed { .. }));
551    }
552
553    #[test]
554    fn no_vars_passthrough() {
555        let r = linux("/h");
556        assert_eq!(r.resolve("/etc/hosts").unwrap(), "/etc/hosts");
557    }
558
559    #[test]
560    fn known_vars_lists_platform_appropriate() {
561        let linux_r = linux("/h");
562        let names = linux_r.known_vars();
563        assert!(names.contains(&"HOME".to_string()));
564        assert!(names.contains(&"XDG_CONFIG".to_string()));
565        assert!(names.contains(&"DOCUMENTS".to_string()));
566        assert!(!names.iter().any(|n| n.starts_with("WIN_")));
567        assert!(!names.iter().any(|n| n.starts_with("MAC_")));
568
569        let win_r = windows("C:/U");
570        let win_names = win_r.known_vars();
571        assert!(win_names.contains(&"WIN_LOCALAPPDATA".to_string()));
572        assert!(win_names.contains(&"WIN_APPDATA".to_string()));
573        assert!(!win_names.iter().any(|n| n.starts_with("MAC_")));
574    }
575
576    #[test]
577    fn xdg_runtime_uses_env_then_tmpdir() {
578        let mut env = HashMap::new();
579        env.insert("HOME".into(), "/h".into());
580        env.insert("XDG_RUNTIME_DIR".into(), "/run/user/1000".into());
581        let r = Resolver::for_platform(Platform::Linux).with_env(env);
582        assert_eq!(r.resolve("${XDG_RUNTIME}").unwrap(), "/run/user/1000");
583
584        let mut env2 = HashMap::new();
585        env2.insert("HOME".into(), "/h".into());
586        env2.insert("TMPDIR".into(), "/var/tmp".into());
587        let r2 = Resolver::for_platform(Platform::Linux).with_env(env2);
588        assert_eq!(r2.resolve("${XDG_RUNTIME}").unwrap(), "/var/tmp");
589    }
590}