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        self.state_dir.join(format!(
255            "{}{}",
256            crate::askpass::WITHHELD_MARKER_PREFIX,
257            encode_marker_alias(alias)
258        ))
259    }
260
261    /// Marker recording that a background run met a security key PIN prompt
262    /// it had no PIN for. Keyed on the flow that ran it, so a container
263    /// refresh never answers for a tunnel. Holds the key path.
264    pub fn askpass_pin_request_marker(&self, flow: &str, alias: &str) -> PathBuf {
265        self.state_dir.join(format!(
266            "{}request.{flow}.{}",
267            crate::security_key::PIN_MARKER_PREFIX,
268            encode_marker_alias(alias)
269        ))
270    }
271
272    /// Marker listing which ssh process received which security key PIN for
273    /// `alias`, one `<pid>\t<key path>` line each.
274    pub fn askpass_pin_used_marker(&self, alias: &str) -> PathBuf {
275        self.state_dir.join(format!(
276            "{}used.{}",
277            crate::security_key::PIN_MARKER_PREFIX,
278            encode_marker_alias(alias)
279        ))
280    }
281
282    /// Marker recording that a security key refused the PIN purple handed
283    /// out. Keyed on a digest of the key path, which is free to be long.
284    pub fn askpass_pin_refused_marker(&self, key_digest: &str) -> PathBuf {
285        self.state_dir.join(format!(
286            "{}refused.{key_digest}",
287            crate::security_key::PIN_MARKER_PREFIX
288        ))
289    }
290
291    /// Marker whose mtime is the last time askpass handed out the PIN of one
292    /// security key, in any run. Keyed on the key path digest.
293    pub fn askpass_pin_last_marker(&self, key_digest: &str) -> PathBuf {
294        self.state_dir.join(format!(
295            "{}last.{key_digest}",
296            crate::security_key::PIN_MARKER_PREFIX
297        ))
298    }
299
300    /// Lock file that queues the hand-outs of one security key's PIN.
301    pub fn askpass_pin_lock_marker(&self, key_digest: &str) -> PathBuf {
302        self.state_dir.join(format!(
303            "{}lock.{key_digest}",
304            crate::security_key::PIN_MARKER_PREFIX
305        ))
306    }
307
308    /// `<cache>/container_cache.jsonl`.
309    pub fn container_cache(&self) -> PathBuf {
310        self.cache_dir.join("container_cache.jsonl")
311    }
312
313    /// `<cache>/last_version_check`.
314    pub fn last_version_check(&self) -> PathBuf {
315        self.cache_dir.join("last_version_check")
316    }
317
318    /// `~/.aws/credentials`, the shared AWS credentials file.
319    pub fn aws_credentials_file(&self) -> PathBuf {
320        self.home.join(".aws").join("credentials")
321    }
322
323    /// `~/.aws/config`, the shared AWS config file. Named profiles live here
324    /// as `[profile <name>]`, and it is the only file that carries the
325    /// assume-role keys.
326    pub fn aws_config_file(&self) -> PathBuf {
327        self.home.join(".aws").join("config")
328    }
329
330    /// `~/.ssh`.
331    pub fn ssh_dir(&self) -> PathBuf {
332        self.home.join(".ssh")
333    }
334
335    /// `path` for display, with the home directory abbreviated to `~`.
336    pub fn abbreviate_home(&self, path: &Path) -> String {
337        match path.strip_prefix(&self.home) {
338            Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
339            _ => path.display().to_string(),
340        }
341    }
342}
343
344/// Percent-encode an alias for a marker filename rather than flattening it,
345/// so `web.prod` and `web_prod` keep their own file and neither can walk out
346/// of the state directory.
347fn encode_marker_alias(alias: &str) -> String {
348    let mut safe = String::with_capacity(alias.len());
349    for c in alias.chars() {
350        match c {
351            '%' => safe.push_str("%25"),
352            '/' => safe.push_str("%2f"),
353            '\\' => safe.push_str("%5c"),
354            '.' => safe.push_str("%2e"),
355            other => safe.push(other),
356        }
357    }
358    safe
359}
360
361/// One category's directory per the precedence documented on
362/// [`Paths::resolve`].
363fn resolve_category(
364    home: &Path,
365    category: Category,
366    var: &impl Fn(&str) -> Option<String>,
367) -> PathBuf {
368    if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
369        return expand_home(home, &explicit);
370    }
371    if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
372        let base = PathBuf::from(base);
373        if base.is_absolute() {
374            return base.join(XDG_APP_DIR);
375        }
376    }
377    home.join(".purple")
378}
379
380/// Expand a leading `~`, `$HOME` or `${HOME}` in an override value. A value
381/// written in a file rather than typed in a shell arrives unexpanded.
382fn expand_home(home: &Path, value: &str) -> PathBuf {
383    if ["~", "$HOME", "${HOME}"].contains(&value) {
384        return home.to_path_buf();
385    }
386    for prefix in ["~/", "$HOME/", "${HOME}/"] {
387        if let Some(rest) = value.strip_prefix(prefix) {
388            return home.join(rest);
389        }
390    }
391    PathBuf::from(value)
392}
393
394/// The resolved environment for one process run: the home-derived paths plus a
395/// snapshot of the process environment variables. Built once via
396/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
397/// re-read on demand.
398#[derive(Clone)]
399pub struct Env {
400    paths: Option<Paths>,
401    vars: HashMap<String, String>,
402    // Test sandbox: owns the temp directory that `paths` points into so it
403    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
404    // production builds; `tempfile` is a dev-dependency.
405    #[cfg(test)]
406    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
407}
408
409impl Env {
410    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
411        Self {
412            paths,
413            vars,
414            #[cfg(test)]
415            _sandbox: None,
416        }
417    }
418
419    /// Capture the real process environment: the home directory and a snapshot
420    /// of all environment variables. The single point where production reads
421    /// `std::env` and `dirs::home_dir`. The category directories resolve from
422    /// the same snapshot.
423    pub fn from_process() -> Self {
424        let vars: HashMap<String, String> = std::env::vars().collect();
425        let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
426        Self::new_inner(paths, vars)
427    }
428
429    /// A test environment rooted at `home` with the legacy layout and no
430    /// environment variables. Add variables with [`Env::with_var`], swap the
431    /// layout with [`Env::with_paths`].
432    pub fn for_test(home: impl Into<PathBuf>) -> Self {
433        Self::new_inner(Some(Paths::new(home)), HashMap::new())
434    }
435
436    /// An environment with neither a home directory nor variables. Models the
437    /// rare case where `dirs::home_dir()` returns `None`.
438    pub fn empty() -> Self {
439        Self::new_inner(None, HashMap::new())
440    }
441
442    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
443    /// Env. Each call is isolated, so parallel tests never share on-disk state
444    /// and need no lock. The default for test `App` fixtures.
445    #[cfg(test)]
446    pub fn sandboxed() -> Self {
447        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
448        let mut env = Self::for_test(dir.path());
449        env._sandbox = Some(std::sync::Arc::new(dir));
450        env
451    }
452
453    /// Builder: set a variable. Chainable, for test construction.
454    #[must_use]
455    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
456        self.vars.insert(key.into(), value.into());
457        self
458    }
459
460    /// Builder: replace the paths, e.g. with a split layout from
461    /// [`Paths::resolve`]. Chainable, for test construction.
462    #[must_use]
463    pub fn with_paths(mut self, paths: Paths) -> Self {
464        self.paths = Some(paths);
465        self
466    }
467
468    /// Home-derived paths, or `None` when the home directory is unknown.
469    pub fn paths(&self) -> Option<&Paths> {
470        self.paths.as_ref()
471    }
472
473    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
474    /// expansion, which references user-chosen names.
475    pub fn var(&self, key: &str) -> Option<&str> {
476        self.vars.get(key).map(String::as_str)
477    }
478
479    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
480    pub fn vault_addr(&self) -> Option<&str> {
481        self.var("VAULT_ADDR")
482    }
483
484    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
485    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
486        match (
487            self.var("AWS_ACCESS_KEY_ID"),
488            self.var("AWS_SECRET_ACCESS_KEY"),
489        ) {
490            (Some(id), Some(secret)) => Some((id, secret)),
491            _ => None,
492        }
493    }
494
495    /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials.
496    pub fn aws_session_token(&self) -> Option<&str> {
497        self.var("AWS_SESSION_TOKEN")
498    }
499
500    /// Where the shared AWS credentials file lives. `AWS_SHARED_CREDENTIALS_FILE`
501    /// relocates it; otherwise it is `~/.aws/credentials`.
502    pub fn aws_credentials_file(&self) -> Option<PathBuf> {
503        self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
504    }
505
506    /// Where the shared AWS config file lives. `AWS_CONFIG_FILE` relocates it;
507    /// otherwise it is `~/.aws/config`.
508    pub fn aws_config_file(&self) -> Option<PathBuf> {
509        self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
510    }
511
512    /// Shared resolution for the two relocatable AWS files.
513    ///
514    /// A leading `~` in the override expands against the known home directory,
515    /// because the AWS CLI accepts that form and a shell does not expand it
516    /// inside a quoted assignment. Exported blank means no file rather than
517    /// the default one: the AWS CLI opens the empty path, finds nothing and
518    /// carries on, so blanking the variable is how you keep a tool away from
519    /// your keys.
520    fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
521        let Some(raw) = self.var(var) else {
522            return self.paths().map(default);
523        };
524        let raw = raw.trim();
525        if raw.is_empty() {
526            return None;
527        }
528        match self.paths() {
529            Some(paths) => Some(expand_home(paths.home(), raw)),
530            // No home to expand against, so the value can only be used as
531            // written.
532            None => Some(PathBuf::from(raw)),
533        }
534    }
535
536    /// `PURPLE_TOKEN`, the self-invocation auth token. A variable that is
537    /// exported but blank (`export PURPLE_TOKEN="$UNSET"`) reads as absent:
538    /// it is ambient shell state, not a token the caller supplied.
539    pub fn purple_token(&self) -> Option<&str> {
540        self.var("PURPLE_TOKEN")
541            .filter(|token| !token.trim().is_empty())
542    }
543
544    /// True when `NO_COLOR` is present (any value), per the no-color convention.
545    pub fn no_color(&self) -> bool {
546        self.vars.contains_key("NO_COLOR")
547    }
548
549    /// `COLORTERM`.
550    pub fn colorterm(&self) -> Option<&str> {
551        self.var("COLORTERM")
552    }
553
554    /// `TERM_PROGRAM`.
555    pub fn term_program(&self) -> Option<&str> {
556        self.var("TERM_PROGRAM")
557    }
558
559    /// `TERM`.
560    pub fn term(&self) -> Option<&str> {
561        self.var("TERM")
562    }
563
564    /// True when running inside tmux (`TMUX` is set).
565    pub fn in_tmux(&self) -> bool {
566        self.vars.contains_key("TMUX")
567    }
568
569    /// Proxy-related variable names that are set to a non-empty value, in a
570    /// stable order. Drives the startup banner's proxy summary.
571    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
572        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
573            .into_iter()
574            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
575            .collect()
576    }
577
578    /// Build a `Command` for `program` whose environment is exactly this Env's
579    /// snapshot. In production the snapshot is the full process environment
580    /// captured at startup, so the subprocess sees the same env it would have
581    /// inherited. Tests construct an `Env` with only the vars they care about
582    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
583    /// behaviour are controlled without mutating the process-global env (no
584    /// `unsafe set_var`, no lock).
585    pub fn command(&self, program: &str) -> std::process::Command {
586        let mut cmd = std::process::Command::new(program);
587        cmd.env_clear();
588        cmd.envs(&self.vars);
589        cmd
590    }
591}
592
593// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
594// VAULT_ADDR). Shows the home directory and the set of variable names only.
595impl std::fmt::Debug for Env {
596    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
598        names.sort_unstable();
599        f.debug_struct("Env")
600            .field("home", &self.paths.as_ref().map(Paths::home))
601            .field("var_names", &names)
602            .finish()
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
611        move |k| {
612            pairs
613                .iter()
614                .find(|(key, _)| *key == k)
615                .map(|(_, v)| (*v).to_string())
616        }
617    }
618
619    #[test]
620    fn paths_derive_under_purple_and_ssh() {
621        let p = Paths::new("/home/u");
622        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
623        assert_eq!(
624            p.preferences(),
625            PathBuf::from("/home/u/.purple/preferences")
626        );
627        assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
628        assert_eq!(
629            p.container_cache(),
630            PathBuf::from("/home/u/.purple/container_cache.jsonl")
631        );
632        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
633        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
634        assert_eq!(
635            p.last_version_check(),
636            PathBuf::from("/home/u/.purple/last_version_check")
637        );
638        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
639        assert_eq!(
640            p.config_original(),
641            PathBuf::from("/home/u/.purple/config.original")
642        );
643        assert_eq!(
644            p.mcp_audit_log(),
645            PathBuf::from("/home/u/.purple/mcp-audit.log")
646        );
647        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
648        assert!(!p.is_split());
649    }
650
651    #[test]
652    fn legacy_layout_puts_every_category_under_dot_purple() {
653        let p = Paths::new("/home/u");
654        for c in Category::ALL {
655            assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
656        }
657    }
658
659    #[test]
660    fn resolve_without_variables_is_the_legacy_layout() {
661        let resolved = Paths::resolve("/home/u", |_| None);
662        assert_eq!(resolved, Paths::new("/home/u"));
663    }
664
665    #[test]
666    fn resolve_honors_each_xdg_variable_independently() {
667        let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
668        let p = Paths::resolve("/home/u", lookup(&vars));
669        assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
670        assert_eq!(
671            p.preferences(),
672            PathBuf::from("/home/u/.config/purple/preferences")
673        );
674        assert_eq!(
675            p.themes_dir(),
676            PathBuf::from("/home/u/.config/purple/themes")
677        );
678        // The other categories stay put when their variable is unset.
679        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
680        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
681        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
682        assert!(p.is_split());
683    }
684
685    #[test]
686    fn resolve_maps_every_file_to_its_category() {
687        let vars = [
688            ("XDG_CONFIG_HOME", "/x/cfg"),
689            ("XDG_DATA_HOME", "/x/data"),
690            ("XDG_STATE_HOME", "/x/state"),
691            ("XDG_CACHE_HOME", "/x/cache"),
692        ];
693        let p = Paths::resolve("/home/u", lookup(&vars));
694        assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
695        assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
696        assert_eq!(
697            p.providers_config(),
698            PathBuf::from("/x/cfg/purple/providers")
699        );
700        assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
701        assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
702        assert_eq!(
703            p.cert_for("web"),
704            PathBuf::from("/x/data/purple/certs/web-cert.pub")
705        );
706        assert_eq!(
707            p.config_original(),
708            PathBuf::from("/x/data/purple/config.original")
709        );
710        assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
711        assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
712        assert_eq!(
713            p.key_activity(),
714            PathBuf::from("/x/state/purple/key_activity.json")
715        );
716        assert_eq!(
717            p.snippet_runs(),
718            PathBuf::from("/x/state/purple/snippet_runs.json")
719        );
720        assert_eq!(
721            p.sync_history(),
722            PathBuf::from("/x/state/purple/sync_history.tsv")
723        );
724        assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
725        assert_eq!(
726            p.mcp_audit_log(),
727            PathBuf::from("/x/state/purple/mcp-audit.log")
728        );
729        assert_eq!(
730            p.askpass_marker("a"),
731            PathBuf::from("/x/state/purple/.askpass_a")
732        );
733        assert_eq!(
734            p.container_cache(),
735            PathBuf::from("/x/cache/purple/container_cache.jsonl")
736        );
737        assert_eq!(
738            p.last_version_check(),
739            PathBuf::from("/x/cache/purple/last_version_check")
740        );
741        // Home-relative paths outside purple's own tree do not move.
742        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
743        assert_eq!(
744            p.aws_credentials_file(),
745            PathBuf::from("/home/u/.aws/credentials")
746        );
747        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
748    }
749
750    #[test]
751    fn resolve_prefers_purple_override_over_xdg() {
752        let vars = [
753            ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
754            ("XDG_CONFIG_HOME", "/home/u/.config"),
755        ];
756        let p = Paths::resolve("/home/u", lookup(&vars));
757        // The override is used verbatim: no `purple` subdirectory appended.
758        assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
759    }
760
761    #[test]
762    fn resolve_expands_home_in_purple_overrides() {
763        for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
764            let vars = [("PURPLE_CONFIG_DIR", value)];
765            let p = Paths::resolve("/home/u", lookup(&vars));
766            assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
767        }
768    }
769
770    #[test]
771    fn resolve_treats_empty_variables_as_unset() {
772        let vars = [
773            ("PURPLE_STATE_DIR", ""),
774            ("XDG_STATE_HOME", "  "),
775            ("XDG_CACHE_HOME", ""),
776        ];
777        let p = Paths::resolve("/home/u", lookup(&vars));
778        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
779        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
780    }
781
782    #[test]
783    fn resolve_ignores_relative_xdg_paths() {
784        // The spec calls a relative XDG path invalid; it must be ignored.
785        let vars = [("XDG_DATA_HOME", "relative/share")];
786        let p = Paths::resolve("/home/u", lookup(&vars));
787        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
788    }
789
790    #[test]
791    fn resolve_empty_purple_override_falls_through_to_xdg() {
792        let vars = [
793            ("PURPLE_DATA_DIR", ""),
794            ("XDG_DATA_HOME", "/home/u/.local/share"),
795        ];
796        let p = Paths::resolve("/home/u", lookup(&vars));
797        assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
798    }
799
800    #[test]
801    fn category_variable_names() {
802        assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
803        assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
804        assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
805        assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
806        assert_eq!(Category::State.name(), "state");
807    }
808
809    #[test]
810    fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
811        let p = Paths::new("/home/u");
812        assert_eq!(
813            p.abbreviate_home(&p.config_original()),
814            "~/.purple/config.original"
815        );
816        assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
817        assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
818    }
819
820    #[test]
821    fn cert_for_uses_alias_filename() {
822        let p = Paths::new("/home/u");
823        assert_eq!(
824            p.cert_for("web-1"),
825            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
826        );
827    }
828
829    #[test]
830    fn askpass_marker_sanitises_traversal_chars() {
831        let p = Paths::new("/home/u");
832        assert_eq!(
833            p.askpass_marker("a/b\\c.d"),
834            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
835        );
836    }
837
838    #[test]
839    fn askpass_withheld_marker_keeps_each_alias_to_itself() {
840        // Provider-synced hosts carry dots, so flattening them would let one
841        // host's marker speak for another.
842        let p = Paths::new("/home/u");
843        assert_ne!(
844            p.askpass_withheld_marker("web.prod"),
845            p.askpass_withheld_marker("web_prod")
846        );
847        assert_eq!(
848            p.askpass_withheld_marker("web.prod"),
849            PathBuf::from("/home/u/.purple/.askpass_withheld.web%2eprod")
850        );
851    }
852
853    #[test]
854    fn askpass_withheld_marker_cannot_walk_out_of_the_state_dir() {
855        let p = Paths::new("/home/u");
856        assert_eq!(
857            p.askpass_withheld_marker("../../etc/passwd"),
858            PathBuf::from("/home/u/.purple/.askpass_withheld.%2e%2e%2f%2e%2e%2fetc%2fpasswd")
859        );
860    }
861
862    #[test]
863    fn askpass_withheld_marker_never_collides_with_a_retry_marker() {
864        // A host literally named `withheld.x` must not land on the file the
865        // withheld marker for `x` uses.
866        let p = Paths::new("/home/u");
867        assert_ne!(
868            p.askpass_marker("withheld.x"),
869            p.askpass_withheld_marker("x")
870        );
871    }
872
873    #[test]
874    fn askpass_pin_markers_cannot_walk_out_of_the_state_dir() {
875        let p = Paths::new("/home/u");
876        assert_eq!(
877            p.askpass_pin_request_marker("tunnel", "../x"),
878            PathBuf::from("/home/u/.purple/.askpass_pin.request.tunnel.%2e%2e%2fx")
879        );
880        assert_eq!(
881            p.askpass_pin_used_marker("web.prod"),
882            PathBuf::from("/home/u/.purple/.askpass_pin.used.web%2eprod")
883        );
884        assert_eq!(
885            p.askpass_pin_refused_marker("abc123"),
886            PathBuf::from("/home/u/.purple/.askpass_pin.refused.abc123")
887        );
888        assert_eq!(
889            p.askpass_pin_last_marker("abc123"),
890            PathBuf::from("/home/u/.purple/.askpass_pin.last.abc123")
891        );
892        assert_eq!(
893            p.askpass_pin_lock_marker("abc123"),
894            PathBuf::from("/home/u/.purple/.askpass_pin.lock.abc123")
895        );
896    }
897
898    #[test]
899    fn askpass_pin_markers_never_collide_with_a_retry_marker() {
900        // A retry marker turns every dot into an underscore, so a host named
901        // `pin.used.x` cannot land on the PIN marker of `x`.
902        let p = Paths::new("/home/u");
903        assert_ne!(
904            p.askpass_marker("pin.used.x"),
905            p.askpass_pin_used_marker("x")
906        );
907    }
908
909    #[test]
910    fn for_test_has_paths_and_no_vars() {
911        let env = Env::for_test("/tmp/x");
912        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
913        assert_eq!(env.var("ANYTHING"), None);
914        assert!(!env.no_color());
915    }
916
917    #[test]
918    fn with_paths_replaces_the_layout() {
919        let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
920        let env = Env::for_test("/tmp/x").with_paths(split.clone());
921        assert_eq!(env.paths(), Some(&split));
922        assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
923    }
924
925    #[test]
926    fn empty_has_no_paths() {
927        let env = Env::empty();
928        assert!(env.paths().is_none());
929    }
930
931    #[test]
932    fn sandboxed_gives_isolated_existing_dirs() {
933        let a = Env::sandboxed();
934        let b = Env::sandboxed();
935        let pa = a.paths().unwrap().home().to_path_buf();
936        let pb = b.paths().unwrap().home().to_path_buf();
937        assert_ne!(pa, pb, "each sandbox is a distinct directory");
938        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
939        // Writing through the derived paths works (atomic_write creates parents).
940        let prefs = a.paths().unwrap().preferences();
941        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
942        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
943    }
944
945    #[test]
946    fn with_var_sets_typed_accessors() {
947        let env = Env::for_test("/tmp/x")
948            .with_var("VAULT_ADDR", "https://vault.example:8200")
949            .with_var("COLORTERM", "truecolor")
950            .with_var("NO_COLOR", "1")
951            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
952        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
953        assert_eq!(env.colorterm(), Some("truecolor"));
954        assert!(env.no_color());
955        assert!(env.in_tmux());
956    }
957
958    #[test]
959    fn aws_credentials_require_both_keys() {
960        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
961        assert_eq!(only_id.aws_credentials(), None);
962        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
963        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
964    }
965
966    #[test]
967    fn purple_token_treats_a_blank_variable_as_absent() {
968        // `export PURPLE_TOKEN="$UNSET"` leaves the variable present and
969        // empty. Reading that as a supplied token makes callers skip their
970        // stored-token fallback and save the blank over a real credential.
971        assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
972        for blank in ["", "   "] {
973            assert_eq!(
974                Env::for_test("/tmp/x")
975                    .with_var("PURPLE_TOKEN", blank)
976                    .purple_token(),
977                None,
978                "a blank PURPLE_TOKEN must read as absent"
979            );
980        }
981        assert_eq!(
982            Env::for_test("/tmp/x")
983                .with_var("PURPLE_TOKEN", "tok")
984                .purple_token(),
985            Some("tok")
986        );
987    }
988
989    #[test]
990    fn aws_session_token_reads_env_var() {
991        let env = Env::for_test("/tmp/x");
992        assert_eq!(env.aws_session_token(), None);
993        let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
994        assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
995    }
996
997    #[test]
998    fn aws_session_token_is_independent_of_key_pair() {
999        // Temporary credentials always arrive as a triple, but the accessor
1000        // must not depend on the key pair being present.
1001        let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
1002        assert_eq!(env.aws_credentials(), None);
1003        assert_eq!(env.aws_session_token(), Some("TOKEN"));
1004    }
1005
1006    #[test]
1007    fn active_proxy_vars_filters_empty_and_orders() {
1008        let env = Env::for_test("/tmp/x")
1009            .with_var("HTTPS_PROXY", "http://proxy:3128")
1010            .with_var("HTTP_PROXY", "")
1011            .with_var("NO_PROXY", "localhost");
1012        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
1013    }
1014
1015    #[test]
1016    fn debug_redacts_secret_values() {
1017        let env = Env::for_test("/tmp/x")
1018            .with_var("PURPLE_TOKEN", "super-secret")
1019            .with_var("VAULT_ADDR", "https://vault.example:8200");
1020        let rendered = format!("{env:?}");
1021        assert!(!rendered.contains("super-secret"));
1022        assert!(!rendered.contains("vault.example"));
1023        assert!(rendered.contains("PURPLE_TOKEN"));
1024        assert!(rendered.contains("VAULT_ADDR"));
1025    }
1026
1027    #[test]
1028    fn from_process_captures_home_and_vars() {
1029        // Smoke test against the real process: home is usually set, and the
1030        // snapshot is internally consistent with the typed accessors.
1031        let env = Env::from_process();
1032        // No assertion on specific vars (CI environments differ); just verify
1033        // the snapshot mechanism works end to end.
1034        let _ = env.paths();
1035        let _ = env.var("PATH");
1036    }
1037    #[test]
1038    fn the_aws_files_default_to_the_dot_aws_directory() {
1039        let env = Env::for_test("/home/u");
1040        assert_eq!(
1041            env.aws_credentials_file(),
1042            Some(PathBuf::from("/home/u/.aws/credentials"))
1043        );
1044        assert_eq!(
1045            env.aws_config_file(),
1046            Some(PathBuf::from("/home/u/.aws/config"))
1047        );
1048    }
1049
1050    #[test]
1051    fn an_absolute_override_relocates_the_aws_file() {
1052        let env = Env::for_test("/home/u")
1053            .with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
1054            .with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
1055        assert_eq!(
1056            env.aws_credentials_file(),
1057            Some(PathBuf::from("/etc/aws/creds"))
1058        );
1059        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
1060    }
1061
1062    #[test]
1063    fn a_home_relative_override_expands_against_the_home_directory() {
1064        // The AWS CLI accepts this form, and a value read from a file rather
1065        // than typed in a shell arrives unexpanded.
1066        let env = Env::for_test("/home/u")
1067            .with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
1068            .with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
1069        assert_eq!(
1070            env.aws_credentials_file(),
1071            Some(PathBuf::from("/home/u/work/creds"))
1072        );
1073        assert_eq!(
1074            env.aws_config_file(),
1075            Some(PathBuf::from("/home/u/work/conf"))
1076        );
1077    }
1078
1079    #[test]
1080    fn a_bare_tilde_override_is_the_home_directory() {
1081        let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
1082        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
1083    }
1084
1085    #[test]
1086    fn an_exported_but_blank_override_means_no_file() {
1087        // Blanking the variable is how a user keeps a tool away from their
1088        // keys. Falling back to the default would read them anyway.
1089        let env = Env::for_test("/home/u")
1090            .with_var("AWS_SHARED_CREDENTIALS_FILE", "")
1091            .with_var("AWS_CONFIG_FILE", "   ");
1092        assert_eq!(env.aws_credentials_file(), None);
1093        assert_eq!(env.aws_config_file(), None);
1094    }
1095
1096    #[test]
1097    fn without_a_home_directory_only_an_override_names_an_aws_file() {
1098        assert_eq!(Env::empty().aws_credentials_file(), None);
1099        assert_eq!(
1100            Env::empty()
1101                .with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
1102                .aws_config_file(),
1103            Some(PathBuf::from("/etc/aws/conf"))
1104        );
1105    }
1106}