Skip to main content

purple_ssh/runtime/
env.rs

1// Resolved process environment and filesystem paths, captured once at the
2// edge (`launcher::run`) and threaded explicitly from there. Replaces ambient
3// `std::env::var` / `dirs::home_dir()` reads scattered through the codebase so
4// tests construct an `Env` directly instead of mutating process-global state.
5//
6// `Env` is immutable after construction, so an `Arc<Env>` crosses thread
7// boundaries into worker closures without a lock.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// The four categories purple's files fall into, following the XDG Base
13/// Directory split. Each resolves to its own directory; by default all four
14/// point at the legacy `~/.purple`.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Category {
17    /// Files the user edits or syncs as dotfiles: preferences, snippets,
18    /// providers, themes.
19    Config,
20    /// Files that are important and not regenerable but not config: signed
21    /// certificates and the original SSH config backup.
22    Data,
23    /// History, recents, activity ledgers, logs and retry markers.
24    State,
25    /// Regenerable caches: the container cache and the version check.
26    Cache,
27}
28
29impl Category {
30    pub const ALL: [Category; 4] = [
31        Category::Config,
32        Category::Data,
33        Category::State,
34        Category::Cache,
35    ];
36
37    /// The explicit purple override variable, e.g. `PURPLE_CONFIG_DIR`.
38    pub fn purple_var(self) -> &'static str {
39        match self {
40            Category::Config => "PURPLE_CONFIG_DIR",
41            Category::Data => "PURPLE_DATA_DIR",
42            Category::State => "PURPLE_STATE_DIR",
43            Category::Cache => "PURPLE_CACHE_DIR",
44        }
45    }
46
47    /// The XDG base directory variable, e.g. `XDG_CONFIG_HOME`.
48    pub fn xdg_var(self) -> &'static str {
49        match self {
50            Category::Config => "XDG_CONFIG_HOME",
51            Category::Data => "XDG_DATA_HOME",
52            Category::State => "XDG_STATE_HOME",
53            Category::Cache => "XDG_CACHE_HOME",
54        }
55    }
56
57    /// Lowercase name for log lines.
58    pub fn name(self) -> &'static str {
59        match self {
60            Category::Config => "config",
61            Category::Data => "data",
62            Category::State => "state",
63            Category::Cache => "cache",
64        }
65    }
66}
67
68/// Subdirectory under an XDG base directory.
69const XDG_APP_DIR: &str = "purple";
70
71/// Home-derived file locations under `~/.purple` and `~/.ssh`. One place that
72/// knows the on-disk layout; every consumer asks here instead of joining the
73/// home directory itself.
74///
75/// The purple files live in four category directories (see [`Category`]).
76/// [`Paths::new`] puts all four at `~/.purple`; [`Paths::resolve`] honors the
77/// `PURPLE_*_DIR` and `XDG_*_HOME` variables per category.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Paths {
80    home: PathBuf,
81    config_dir: PathBuf,
82    data_dir: PathBuf,
83    state_dir: PathBuf,
84    cache_dir: PathBuf,
85}
86
87impl Paths {
88    /// The legacy layout: every category under `~/.purple`.
89    pub fn new(home: impl Into<PathBuf>) -> Self {
90        let home = home.into();
91        let legacy = home.join(".purple");
92        Self {
93            home,
94            config_dir: legacy.clone(),
95            data_dir: legacy.clone(),
96            state_dir: legacy.clone(),
97            cache_dir: legacy,
98        }
99    }
100
101    /// Resolve each category directory from the environment. Precedence per
102    /// category: `PURPLE_<CATEGORY>_DIR` when set and non-empty (a leading
103    /// `~/` expands to the home directory), then `$XDG_<CATEGORY>_HOME/purple`
104    /// when that variable is set, non-empty and absolute (a relative XDG path
105    /// is invalid per the spec and ignored), else the legacy `~/.purple`.
106    pub fn resolve(home: impl Into<PathBuf>, var: impl Fn(&str) -> Option<String>) -> Self {
107        let mut paths = Self::new(home);
108        for category in Category::ALL {
109            let dir = resolve_category(&paths.home, category, &var);
110            *paths.dir_mut(category) = dir;
111        }
112        paths
113    }
114
115    fn dir_mut(&mut self, category: Category) -> &mut PathBuf {
116        match category {
117            Category::Config => &mut self.config_dir,
118            Category::Data => &mut self.data_dir,
119            Category::State => &mut self.state_dir,
120            Category::Cache => &mut self.cache_dir,
121        }
122    }
123
124    pub fn home(&self) -> &Path {
125        &self.home
126    }
127
128    /// `~/.purple`, the single directory every category used before the
129    /// split. Only the migration reads from here.
130    pub fn legacy_dir(&self) -> PathBuf {
131        self.home.join(".purple")
132    }
133
134    /// The directory a category resolved to.
135    pub fn dir(&self, category: Category) -> &Path {
136        match category {
137            Category::Config => &self.config_dir,
138            Category::Data => &self.data_dir,
139            Category::State => &self.state_dir,
140            Category::Cache => &self.cache_dir,
141        }
142    }
143
144    /// Preferences, snippets, providers and themes.
145    pub fn config_dir(&self) -> &Path {
146        &self.config_dir
147    }
148
149    /// Signed certificates and the original SSH config backup.
150    pub fn data_dir(&self) -> &Path {
151        &self.data_dir
152    }
153
154    /// History, recents, ledgers, logs and retry markers.
155    pub fn state_dir(&self) -> &Path {
156        &self.state_dir
157    }
158
159    /// Container cache and version check.
160    pub fn cache_dir(&self) -> &Path {
161        &self.cache_dir
162    }
163
164    /// True when at least one category lives outside `~/.purple`.
165    pub fn is_split(&self) -> bool {
166        let legacy = self.legacy_dir();
167        Category::ALL.iter().any(|c| self.dir(*c) != legacy)
168    }
169
170    /// `<config>/preferences`.
171    pub fn preferences(&self) -> PathBuf {
172        self.config_dir.join("preferences")
173    }
174
175    /// `<config>/snippets`, the INI-style snippet store.
176    pub fn snippets_file(&self) -> PathBuf {
177        self.config_dir.join("snippets")
178    }
179
180    /// `<config>/providers`, the provider config file.
181    pub fn providers_config(&self) -> PathBuf {
182        self.config_dir.join("providers")
183    }
184
185    /// `<config>/themes`, the custom theme directory.
186    pub fn themes_dir(&self) -> PathBuf {
187        self.config_dir.join("themes")
188    }
189
190    /// `<data>/certs`.
191    pub fn certs_dir(&self) -> PathBuf {
192        self.data_dir.join("certs")
193    }
194
195    /// `<data>/certs/<alias>-cert.pub`.
196    pub fn cert_for(&self, alias: &str) -> PathBuf {
197        self.certs_dir().join(format!("{alias}-cert.pub"))
198    }
199
200    /// `<data>/config.original`, the untouched SSH config backup taken on
201    /// the first launch.
202    pub fn config_original(&self) -> PathBuf {
203        self.data_dir.join("config.original")
204    }
205
206    /// `<state>/history.tsv`.
207    pub fn history(&self) -> PathBuf {
208        self.state_dir.join("history.tsv")
209    }
210
211    /// `<state>/recents.json`.
212    pub fn recents(&self) -> PathBuf {
213        self.state_dir.join("recents.json")
214    }
215
216    /// `<state>/key_activity.json`.
217    pub fn key_activity(&self) -> PathBuf {
218        self.state_dir.join("key_activity.json")
219    }
220
221    /// `<state>/snippet_runs.json`.
222    pub fn snippet_runs(&self) -> PathBuf {
223        self.state_dir.join("snippet_runs.json")
224    }
225
226    /// `<state>/sync_history.tsv`.
227    pub fn sync_history(&self) -> PathBuf {
228        self.state_dir.join("sync_history.tsv")
229    }
230
231    /// `<state>/purple.log`.
232    pub fn log_file(&self) -> PathBuf {
233        self.state_dir.join("purple.log")
234    }
235
236    /// `<state>/mcp-audit.log`.
237    pub fn mcp_audit_log(&self) -> PathBuf {
238        self.state_dir.join("mcp-audit.log")
239    }
240
241    /// Askpass retry marker `<state>/.askpass_<safe>`. The alias is
242    /// sanitized (`/`, `\`, `.` become `_`) to prevent path traversal.
243    pub fn askpass_marker(&self, alias: &str) -> PathBuf {
244        let safe = alias.replace(['/', '\\', '.'], "_");
245        self.state_dir.join(format!(".askpass_{safe}"))
246    }
247
248    /// Marker recording that a background run met a prompt it could not
249    /// place. The dot after `withheld` keeps this apart from a retry marker,
250    /// whose own sanitizer turns every dot into an underscore. The alias is
251    /// percent-encoded rather than flattened, so `web.prod` and `web_prod`
252    /// keep their own file and neither can walk out of the state directory.
253    pub fn askpass_withheld_marker(&self, alias: &str) -> PathBuf {
254        let mut safe = String::with_capacity(alias.len());
255        for c in alias.chars() {
256            match c {
257                '%' => safe.push_str("%25"),
258                '/' => safe.push_str("%2f"),
259                '\\' => safe.push_str("%5c"),
260                '.' => safe.push_str("%2e"),
261                other => safe.push(other),
262            }
263        }
264        self.state_dir
265            .join(format!("{}{safe}", crate::askpass::WITHHELD_MARKER_PREFIX))
266    }
267
268    /// `<cache>/container_cache.jsonl`.
269    pub fn container_cache(&self) -> PathBuf {
270        self.cache_dir.join("container_cache.jsonl")
271    }
272
273    /// `<cache>/last_version_check`.
274    pub fn last_version_check(&self) -> PathBuf {
275        self.cache_dir.join("last_version_check")
276    }
277
278    /// `~/.aws/credentials`, the shared AWS credentials file.
279    pub fn aws_credentials_file(&self) -> PathBuf {
280        self.home.join(".aws").join("credentials")
281    }
282
283    /// `~/.aws/config`, the shared AWS config file. Named profiles live here
284    /// as `[profile <name>]`, and it is the only file that carries the
285    /// assume-role keys.
286    pub fn aws_config_file(&self) -> PathBuf {
287        self.home.join(".aws").join("config")
288    }
289
290    /// `~/.ssh`.
291    pub fn ssh_dir(&self) -> PathBuf {
292        self.home.join(".ssh")
293    }
294
295    /// `path` for display, with the home directory abbreviated to `~`.
296    pub fn abbreviate_home(&self, path: &Path) -> String {
297        match path.strip_prefix(&self.home) {
298            Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
299            _ => path.display().to_string(),
300        }
301    }
302}
303
304/// One category's directory per the precedence documented on
305/// [`Paths::resolve`].
306fn resolve_category(
307    home: &Path,
308    category: Category,
309    var: &impl Fn(&str) -> Option<String>,
310) -> PathBuf {
311    if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
312        return expand_home(home, &explicit);
313    }
314    if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
315        let base = PathBuf::from(base);
316        if base.is_absolute() {
317            return base.join(XDG_APP_DIR);
318        }
319    }
320    home.join(".purple")
321}
322
323/// Expand a leading `~`, `$HOME` or `${HOME}` in an override value. A value
324/// written in a file rather than typed in a shell arrives unexpanded.
325fn expand_home(home: &Path, value: &str) -> PathBuf {
326    if ["~", "$HOME", "${HOME}"].contains(&value) {
327        return home.to_path_buf();
328    }
329    for prefix in ["~/", "$HOME/", "${HOME}/"] {
330        if let Some(rest) = value.strip_prefix(prefix) {
331            return home.join(rest);
332        }
333    }
334    PathBuf::from(value)
335}
336
337/// The resolved environment for one process run: the home-derived paths plus a
338/// snapshot of the process environment variables. Built once via
339/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
340/// re-read on demand.
341#[derive(Clone)]
342pub struct Env {
343    paths: Option<Paths>,
344    vars: HashMap<String, String>,
345    // Test sandbox: owns the temp directory that `paths` points into so it
346    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
347    // production builds; `tempfile` is a dev-dependency.
348    #[cfg(test)]
349    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
350}
351
352impl Env {
353    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
354        Self {
355            paths,
356            vars,
357            #[cfg(test)]
358            _sandbox: None,
359        }
360    }
361
362    /// Capture the real process environment: the home directory and a snapshot
363    /// of all environment variables. The single point where production reads
364    /// `std::env` and `dirs::home_dir`. The category directories resolve from
365    /// the same snapshot.
366    pub fn from_process() -> Self {
367        let vars: HashMap<String, String> = std::env::vars().collect();
368        let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
369        Self::new_inner(paths, vars)
370    }
371
372    /// A test environment rooted at `home` with the legacy layout and no
373    /// environment variables. Add variables with [`Env::with_var`], swap the
374    /// layout with [`Env::with_paths`].
375    pub fn for_test(home: impl Into<PathBuf>) -> Self {
376        Self::new_inner(Some(Paths::new(home)), HashMap::new())
377    }
378
379    /// An environment with neither a home directory nor variables. Models the
380    /// rare case where `dirs::home_dir()` returns `None`.
381    pub fn empty() -> Self {
382        Self::new_inner(None, HashMap::new())
383    }
384
385    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
386    /// Env. Each call is isolated, so parallel tests never share on-disk state
387    /// and need no lock. The default for test `App` fixtures.
388    #[cfg(test)]
389    pub fn sandboxed() -> Self {
390        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
391        let mut env = Self::for_test(dir.path());
392        env._sandbox = Some(std::sync::Arc::new(dir));
393        env
394    }
395
396    /// Builder: set a variable. Chainable, for test construction.
397    #[must_use]
398    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
399        self.vars.insert(key.into(), value.into());
400        self
401    }
402
403    /// Builder: replace the paths, e.g. with a split layout from
404    /// [`Paths::resolve`]. Chainable, for test construction.
405    #[must_use]
406    pub fn with_paths(mut self, paths: Paths) -> Self {
407        self.paths = Some(paths);
408        self
409    }
410
411    /// Home-derived paths, or `None` when the home directory is unknown.
412    pub fn paths(&self) -> Option<&Paths> {
413        self.paths.as_ref()
414    }
415
416    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
417    /// expansion, which references user-chosen names.
418    pub fn var(&self, key: &str) -> Option<&str> {
419        self.vars.get(key).map(String::as_str)
420    }
421
422    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
423    pub fn vault_addr(&self) -> Option<&str> {
424        self.var("VAULT_ADDR")
425    }
426
427    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
428    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
429        match (
430            self.var("AWS_ACCESS_KEY_ID"),
431            self.var("AWS_SECRET_ACCESS_KEY"),
432        ) {
433            (Some(id), Some(secret)) => Some((id, secret)),
434            _ => None,
435        }
436    }
437
438    /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials.
439    pub fn aws_session_token(&self) -> Option<&str> {
440        self.var("AWS_SESSION_TOKEN")
441    }
442
443    /// Where the shared AWS credentials file lives. `AWS_SHARED_CREDENTIALS_FILE`
444    /// relocates it; otherwise it is `~/.aws/credentials`.
445    pub fn aws_credentials_file(&self) -> Option<PathBuf> {
446        self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
447    }
448
449    /// Where the shared AWS config file lives. `AWS_CONFIG_FILE` relocates it;
450    /// otherwise it is `~/.aws/config`.
451    pub fn aws_config_file(&self) -> Option<PathBuf> {
452        self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
453    }
454
455    /// Shared resolution for the two relocatable AWS files.
456    ///
457    /// A leading `~` in the override expands against the known home directory,
458    /// because the AWS CLI accepts that form and a shell does not expand it
459    /// inside a quoted assignment. Exported blank means no file rather than
460    /// the default one: the AWS CLI opens the empty path, finds nothing and
461    /// carries on, so blanking the variable is how you keep a tool away from
462    /// your keys.
463    fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
464        let Some(raw) = self.var(var) else {
465            return self.paths().map(default);
466        };
467        let raw = raw.trim();
468        if raw.is_empty() {
469            return None;
470        }
471        match self.paths() {
472            Some(paths) => Some(expand_home(paths.home(), raw)),
473            // No home to expand against, so the value can only be used as
474            // written.
475            None => Some(PathBuf::from(raw)),
476        }
477    }
478
479    /// `PURPLE_TOKEN`, the self-invocation auth token. A variable that is
480    /// exported but blank (`export PURPLE_TOKEN="$UNSET"`) reads as absent:
481    /// it is ambient shell state, not a token the caller supplied.
482    pub fn purple_token(&self) -> Option<&str> {
483        self.var("PURPLE_TOKEN")
484            .filter(|token| !token.trim().is_empty())
485    }
486
487    /// True when `NO_COLOR` is present (any value), per the no-color convention.
488    pub fn no_color(&self) -> bool {
489        self.vars.contains_key("NO_COLOR")
490    }
491
492    /// `COLORTERM`.
493    pub fn colorterm(&self) -> Option<&str> {
494        self.var("COLORTERM")
495    }
496
497    /// `TERM_PROGRAM`.
498    pub fn term_program(&self) -> Option<&str> {
499        self.var("TERM_PROGRAM")
500    }
501
502    /// `TERM`.
503    pub fn term(&self) -> Option<&str> {
504        self.var("TERM")
505    }
506
507    /// True when running inside tmux (`TMUX` is set).
508    pub fn in_tmux(&self) -> bool {
509        self.vars.contains_key("TMUX")
510    }
511
512    /// Proxy-related variable names that are set to a non-empty value, in a
513    /// stable order. Drives the startup banner's proxy summary.
514    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
515        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
516            .into_iter()
517            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
518            .collect()
519    }
520
521    /// Build a `Command` for `program` whose environment is exactly this Env's
522    /// snapshot. In production the snapshot is the full process environment
523    /// captured at startup, so the subprocess sees the same env it would have
524    /// inherited. Tests construct an `Env` with only the vars they care about
525    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
526    /// behaviour are controlled without mutating the process-global env (no
527    /// `unsafe set_var`, no lock).
528    pub fn command(&self, program: &str) -> std::process::Command {
529        let mut cmd = std::process::Command::new(program);
530        cmd.env_clear();
531        cmd.envs(&self.vars);
532        cmd
533    }
534}
535
536// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
537// VAULT_ADDR). Shows the home directory and the set of variable names only.
538impl std::fmt::Debug for Env {
539    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
541        names.sort_unstable();
542        f.debug_struct("Env")
543            .field("home", &self.paths.as_ref().map(Paths::home))
544            .field("var_names", &names)
545            .finish()
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
554        move |k| {
555            pairs
556                .iter()
557                .find(|(key, _)| *key == k)
558                .map(|(_, v)| (*v).to_string())
559        }
560    }
561
562    #[test]
563    fn paths_derive_under_purple_and_ssh() {
564        let p = Paths::new("/home/u");
565        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
566        assert_eq!(
567            p.preferences(),
568            PathBuf::from("/home/u/.purple/preferences")
569        );
570        assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
571        assert_eq!(
572            p.container_cache(),
573            PathBuf::from("/home/u/.purple/container_cache.jsonl")
574        );
575        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
576        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
577        assert_eq!(
578            p.last_version_check(),
579            PathBuf::from("/home/u/.purple/last_version_check")
580        );
581        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
582        assert_eq!(
583            p.config_original(),
584            PathBuf::from("/home/u/.purple/config.original")
585        );
586        assert_eq!(
587            p.mcp_audit_log(),
588            PathBuf::from("/home/u/.purple/mcp-audit.log")
589        );
590        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
591        assert!(!p.is_split());
592    }
593
594    #[test]
595    fn legacy_layout_puts_every_category_under_dot_purple() {
596        let p = Paths::new("/home/u");
597        for c in Category::ALL {
598            assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
599        }
600    }
601
602    #[test]
603    fn resolve_without_variables_is_the_legacy_layout() {
604        let resolved = Paths::resolve("/home/u", |_| None);
605        assert_eq!(resolved, Paths::new("/home/u"));
606    }
607
608    #[test]
609    fn resolve_honors_each_xdg_variable_independently() {
610        let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
611        let p = Paths::resolve("/home/u", lookup(&vars));
612        assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
613        assert_eq!(
614            p.preferences(),
615            PathBuf::from("/home/u/.config/purple/preferences")
616        );
617        assert_eq!(
618            p.themes_dir(),
619            PathBuf::from("/home/u/.config/purple/themes")
620        );
621        // The other categories stay put when their variable is unset.
622        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
623        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
624        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
625        assert!(p.is_split());
626    }
627
628    #[test]
629    fn resolve_maps_every_file_to_its_category() {
630        let vars = [
631            ("XDG_CONFIG_HOME", "/x/cfg"),
632            ("XDG_DATA_HOME", "/x/data"),
633            ("XDG_STATE_HOME", "/x/state"),
634            ("XDG_CACHE_HOME", "/x/cache"),
635        ];
636        let p = Paths::resolve("/home/u", lookup(&vars));
637        assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
638        assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
639        assert_eq!(
640            p.providers_config(),
641            PathBuf::from("/x/cfg/purple/providers")
642        );
643        assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
644        assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
645        assert_eq!(
646            p.cert_for("web"),
647            PathBuf::from("/x/data/purple/certs/web-cert.pub")
648        );
649        assert_eq!(
650            p.config_original(),
651            PathBuf::from("/x/data/purple/config.original")
652        );
653        assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
654        assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
655        assert_eq!(
656            p.key_activity(),
657            PathBuf::from("/x/state/purple/key_activity.json")
658        );
659        assert_eq!(
660            p.snippet_runs(),
661            PathBuf::from("/x/state/purple/snippet_runs.json")
662        );
663        assert_eq!(
664            p.sync_history(),
665            PathBuf::from("/x/state/purple/sync_history.tsv")
666        );
667        assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
668        assert_eq!(
669            p.mcp_audit_log(),
670            PathBuf::from("/x/state/purple/mcp-audit.log")
671        );
672        assert_eq!(
673            p.askpass_marker("a"),
674            PathBuf::from("/x/state/purple/.askpass_a")
675        );
676        assert_eq!(
677            p.container_cache(),
678            PathBuf::from("/x/cache/purple/container_cache.jsonl")
679        );
680        assert_eq!(
681            p.last_version_check(),
682            PathBuf::from("/x/cache/purple/last_version_check")
683        );
684        // Home-relative paths outside purple's own tree do not move.
685        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
686        assert_eq!(
687            p.aws_credentials_file(),
688            PathBuf::from("/home/u/.aws/credentials")
689        );
690        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
691    }
692
693    #[test]
694    fn resolve_prefers_purple_override_over_xdg() {
695        let vars = [
696            ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
697            ("XDG_CONFIG_HOME", "/home/u/.config"),
698        ];
699        let p = Paths::resolve("/home/u", lookup(&vars));
700        // The override is used verbatim: no `purple` subdirectory appended.
701        assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
702    }
703
704    #[test]
705    fn resolve_expands_home_in_purple_overrides() {
706        for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
707            let vars = [("PURPLE_CONFIG_DIR", value)];
708            let p = Paths::resolve("/home/u", lookup(&vars));
709            assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
710        }
711    }
712
713    #[test]
714    fn resolve_treats_empty_variables_as_unset() {
715        let vars = [
716            ("PURPLE_STATE_DIR", ""),
717            ("XDG_STATE_HOME", "  "),
718            ("XDG_CACHE_HOME", ""),
719        ];
720        let p = Paths::resolve("/home/u", lookup(&vars));
721        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
722        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
723    }
724
725    #[test]
726    fn resolve_ignores_relative_xdg_paths() {
727        // The spec calls a relative XDG path invalid; it must be ignored.
728        let vars = [("XDG_DATA_HOME", "relative/share")];
729        let p = Paths::resolve("/home/u", lookup(&vars));
730        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
731    }
732
733    #[test]
734    fn resolve_empty_purple_override_falls_through_to_xdg() {
735        let vars = [
736            ("PURPLE_DATA_DIR", ""),
737            ("XDG_DATA_HOME", "/home/u/.local/share"),
738        ];
739        let p = Paths::resolve("/home/u", lookup(&vars));
740        assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
741    }
742
743    #[test]
744    fn category_variable_names() {
745        assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
746        assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
747        assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
748        assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
749        assert_eq!(Category::State.name(), "state");
750    }
751
752    #[test]
753    fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
754        let p = Paths::new("/home/u");
755        assert_eq!(
756            p.abbreviate_home(&p.config_original()),
757            "~/.purple/config.original"
758        );
759        assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
760        assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
761    }
762
763    #[test]
764    fn cert_for_uses_alias_filename() {
765        let p = Paths::new("/home/u");
766        assert_eq!(
767            p.cert_for("web-1"),
768            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
769        );
770    }
771
772    #[test]
773    fn askpass_marker_sanitises_traversal_chars() {
774        let p = Paths::new("/home/u");
775        assert_eq!(
776            p.askpass_marker("a/b\\c.d"),
777            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
778        );
779    }
780
781    #[test]
782    fn askpass_withheld_marker_keeps_each_alias_to_itself() {
783        // Provider-synced hosts carry dots, so flattening them would let one
784        // host's marker speak for another.
785        let p = Paths::new("/home/u");
786        assert_ne!(
787            p.askpass_withheld_marker("web.prod"),
788            p.askpass_withheld_marker("web_prod")
789        );
790        assert_eq!(
791            p.askpass_withheld_marker("web.prod"),
792            PathBuf::from("/home/u/.purple/.askpass_withheld.web%2eprod")
793        );
794    }
795
796    #[test]
797    fn askpass_withheld_marker_cannot_walk_out_of_the_state_dir() {
798        let p = Paths::new("/home/u");
799        assert_eq!(
800            p.askpass_withheld_marker("../../etc/passwd"),
801            PathBuf::from("/home/u/.purple/.askpass_withheld.%2e%2e%2f%2e%2e%2fetc%2fpasswd")
802        );
803    }
804
805    #[test]
806    fn askpass_withheld_marker_never_collides_with_a_retry_marker() {
807        // A host literally named `withheld.x` must not land on the file the
808        // withheld marker for `x` uses.
809        let p = Paths::new("/home/u");
810        assert_ne!(
811            p.askpass_marker("withheld.x"),
812            p.askpass_withheld_marker("x")
813        );
814    }
815
816    #[test]
817    fn for_test_has_paths_and_no_vars() {
818        let env = Env::for_test("/tmp/x");
819        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
820        assert_eq!(env.var("ANYTHING"), None);
821        assert!(!env.no_color());
822    }
823
824    #[test]
825    fn with_paths_replaces_the_layout() {
826        let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
827        let env = Env::for_test("/tmp/x").with_paths(split.clone());
828        assert_eq!(env.paths(), Some(&split));
829        assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
830    }
831
832    #[test]
833    fn empty_has_no_paths() {
834        let env = Env::empty();
835        assert!(env.paths().is_none());
836    }
837
838    #[test]
839    fn sandboxed_gives_isolated_existing_dirs() {
840        let a = Env::sandboxed();
841        let b = Env::sandboxed();
842        let pa = a.paths().unwrap().home().to_path_buf();
843        let pb = b.paths().unwrap().home().to_path_buf();
844        assert_ne!(pa, pb, "each sandbox is a distinct directory");
845        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
846        // Writing through the derived paths works (atomic_write creates parents).
847        let prefs = a.paths().unwrap().preferences();
848        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
849        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
850    }
851
852    #[test]
853    fn with_var_sets_typed_accessors() {
854        let env = Env::for_test("/tmp/x")
855            .with_var("VAULT_ADDR", "https://vault.example:8200")
856            .with_var("COLORTERM", "truecolor")
857            .with_var("NO_COLOR", "1")
858            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
859        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
860        assert_eq!(env.colorterm(), Some("truecolor"));
861        assert!(env.no_color());
862        assert!(env.in_tmux());
863    }
864
865    #[test]
866    fn aws_credentials_require_both_keys() {
867        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
868        assert_eq!(only_id.aws_credentials(), None);
869        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
870        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
871    }
872
873    #[test]
874    fn purple_token_treats_a_blank_variable_as_absent() {
875        // `export PURPLE_TOKEN="$UNSET"` leaves the variable present and
876        // empty. Reading that as a supplied token makes callers skip their
877        // stored-token fallback and save the blank over a real credential.
878        assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
879        for blank in ["", "   "] {
880            assert_eq!(
881                Env::for_test("/tmp/x")
882                    .with_var("PURPLE_TOKEN", blank)
883                    .purple_token(),
884                None,
885                "a blank PURPLE_TOKEN must read as absent"
886            );
887        }
888        assert_eq!(
889            Env::for_test("/tmp/x")
890                .with_var("PURPLE_TOKEN", "tok")
891                .purple_token(),
892            Some("tok")
893        );
894    }
895
896    #[test]
897    fn aws_session_token_reads_env_var() {
898        let env = Env::for_test("/tmp/x");
899        assert_eq!(env.aws_session_token(), None);
900        let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
901        assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
902    }
903
904    #[test]
905    fn aws_session_token_is_independent_of_key_pair() {
906        // Temporary credentials always arrive as a triple, but the accessor
907        // must not depend on the key pair being present.
908        let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
909        assert_eq!(env.aws_credentials(), None);
910        assert_eq!(env.aws_session_token(), Some("TOKEN"));
911    }
912
913    #[test]
914    fn active_proxy_vars_filters_empty_and_orders() {
915        let env = Env::for_test("/tmp/x")
916            .with_var("HTTPS_PROXY", "http://proxy:3128")
917            .with_var("HTTP_PROXY", "")
918            .with_var("NO_PROXY", "localhost");
919        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
920    }
921
922    #[test]
923    fn debug_redacts_secret_values() {
924        let env = Env::for_test("/tmp/x")
925            .with_var("PURPLE_TOKEN", "super-secret")
926            .with_var("VAULT_ADDR", "https://vault.example:8200");
927        let rendered = format!("{env:?}");
928        assert!(!rendered.contains("super-secret"));
929        assert!(!rendered.contains("vault.example"));
930        assert!(rendered.contains("PURPLE_TOKEN"));
931        assert!(rendered.contains("VAULT_ADDR"));
932    }
933
934    #[test]
935    fn from_process_captures_home_and_vars() {
936        // Smoke test against the real process: home is usually set, and the
937        // snapshot is internally consistent with the typed accessors.
938        let env = Env::from_process();
939        // No assertion on specific vars (CI environments differ); just verify
940        // the snapshot mechanism works end to end.
941        let _ = env.paths();
942        let _ = env.var("PATH");
943    }
944    #[test]
945    fn the_aws_files_default_to_the_dot_aws_directory() {
946        let env = Env::for_test("/home/u");
947        assert_eq!(
948            env.aws_credentials_file(),
949            Some(PathBuf::from("/home/u/.aws/credentials"))
950        );
951        assert_eq!(
952            env.aws_config_file(),
953            Some(PathBuf::from("/home/u/.aws/config"))
954        );
955    }
956
957    #[test]
958    fn an_absolute_override_relocates_the_aws_file() {
959        let env = Env::for_test("/home/u")
960            .with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
961            .with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
962        assert_eq!(
963            env.aws_credentials_file(),
964            Some(PathBuf::from("/etc/aws/creds"))
965        );
966        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
967    }
968
969    #[test]
970    fn a_home_relative_override_expands_against_the_home_directory() {
971        // The AWS CLI accepts this form, and a value read from a file rather
972        // than typed in a shell arrives unexpanded.
973        let env = Env::for_test("/home/u")
974            .with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
975            .with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
976        assert_eq!(
977            env.aws_credentials_file(),
978            Some(PathBuf::from("/home/u/work/creds"))
979        );
980        assert_eq!(
981            env.aws_config_file(),
982            Some(PathBuf::from("/home/u/work/conf"))
983        );
984    }
985
986    #[test]
987    fn a_bare_tilde_override_is_the_home_directory() {
988        let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
989        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
990    }
991
992    #[test]
993    fn an_exported_but_blank_override_means_no_file() {
994        // Blanking the variable is how a user keeps a tool away from their
995        // keys. Falling back to the default would read them anyway.
996        let env = Env::for_test("/home/u")
997            .with_var("AWS_SHARED_CREDENTIALS_FILE", "")
998            .with_var("AWS_CONFIG_FILE", "   ");
999        assert_eq!(env.aws_credentials_file(), None);
1000        assert_eq!(env.aws_config_file(), None);
1001    }
1002
1003    #[test]
1004    fn without_a_home_directory_only_an_override_names_an_aws_file() {
1005        assert_eq!(Env::empty().aws_credentials_file(), None);
1006        assert_eq!(
1007            Env::empty()
1008                .with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
1009                .aws_config_file(),
1010            Some(PathBuf::from("/etc/aws/conf"))
1011        );
1012    }
1013}