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/// Home-derived file locations under `~/.purple` and `~/.ssh`. One place that
13/// knows the on-disk layout; every consumer asks here instead of joining the
14/// home directory itself.
15#[derive(Clone)]
16pub struct Paths {
17    home: PathBuf,
18}
19
20impl Paths {
21    pub fn new(home: impl Into<PathBuf>) -> Self {
22        Self { home: home.into() }
23    }
24
25    pub fn home(&self) -> &Path {
26        &self.home
27    }
28
29    /// `~/.purple`.
30    pub fn purple_dir(&self) -> PathBuf {
31        self.home.join(".purple")
32    }
33
34    /// `~/.purple/preferences`.
35    pub fn preferences(&self) -> PathBuf {
36        self.purple_dir().join("preferences")
37    }
38
39    /// `~/.purple/snippets`.
40    pub fn snippets_dir(&self) -> PathBuf {
41        self.purple_dir().join("snippets")
42    }
43
44    /// `~/.purple/container_cache.jsonl`.
45    pub fn container_cache(&self) -> PathBuf {
46        self.purple_dir().join("container_cache.jsonl")
47    }
48
49    /// `~/.purple/purple.log`.
50    pub fn log_file(&self) -> PathBuf {
51        self.purple_dir().join("purple.log")
52    }
53
54    /// `~/.purple/history.tsv`.
55    pub fn history(&self) -> PathBuf {
56        self.purple_dir().join("history.tsv")
57    }
58
59    /// `~/.purple/key_activity.json`.
60    pub fn key_activity(&self) -> PathBuf {
61        self.purple_dir().join("key_activity.json")
62    }
63
64    /// `~/.purple/snippet_runs.json`.
65    pub fn snippet_runs(&self) -> PathBuf {
66        self.purple_dir().join("snippet_runs.json")
67    }
68
69    /// `~/.purple/sync_history.tsv`.
70    pub fn sync_history(&self) -> PathBuf {
71        self.purple_dir().join("sync_history.tsv")
72    }
73
74    /// `~/.purple/recents.json`.
75    pub fn recents(&self) -> PathBuf {
76        self.purple_dir().join("recents.json")
77    }
78
79    /// `~/.purple/providers`, the provider config file.
80    pub fn providers_config(&self) -> PathBuf {
81        self.purple_dir().join("providers")
82    }
83
84    /// `~/.purple/themes`, the custom theme directory.
85    pub fn themes_dir(&self) -> PathBuf {
86        self.purple_dir().join("themes")
87    }
88
89    /// `~/.aws/credentials`, the shared AWS credentials file.
90    pub fn aws_credentials_file(&self) -> PathBuf {
91        self.home.join(".aws").join("credentials")
92    }
93
94    /// `~/.purple/last_version_check`.
95    pub fn last_version_check(&self) -> PathBuf {
96        self.purple_dir().join("last_version_check")
97    }
98
99    /// `~/.purple/certs`.
100    pub fn certs_dir(&self) -> PathBuf {
101        self.purple_dir().join("certs")
102    }
103
104    /// `~/.purple/certs/<alias>-cert.pub`.
105    pub fn cert_for(&self, alias: &str) -> PathBuf {
106        self.certs_dir().join(format!("{alias}-cert.pub"))
107    }
108
109    /// `~/.ssh`.
110    pub fn ssh_dir(&self) -> PathBuf {
111        self.home.join(".ssh")
112    }
113
114    /// Askpass retry marker `~/.purple/.askpass_<safe>`. The alias is
115    /// sanitised (`/`, `\`, `.` become `_`) to prevent path traversal.
116    pub fn askpass_marker(&self, alias: &str) -> PathBuf {
117        let safe = alias.replace(['/', '\\', '.'], "_");
118        self.purple_dir().join(format!(".askpass_{safe}"))
119    }
120}
121
122/// The resolved environment for one process run: the home-derived paths plus a
123/// snapshot of the process environment variables. Built once via
124/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
125/// re-read on demand.
126#[derive(Clone)]
127pub struct Env {
128    paths: Option<Paths>,
129    vars: HashMap<String, String>,
130    // Test sandbox: owns the temp directory that `paths` points into so it
131    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
132    // production builds; `tempfile` is a dev-dependency.
133    #[cfg(test)]
134    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
135}
136
137impl Env {
138    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
139        Self {
140            paths,
141            vars,
142            #[cfg(test)]
143            _sandbox: None,
144        }
145    }
146
147    /// Capture the real process environment: the home directory and a snapshot
148    /// of all environment variables. The single point where production reads
149    /// `std::env` and `dirs::home_dir`.
150    pub fn from_process() -> Self {
151        Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
152    }
153
154    /// A test environment rooted at `home` with no environment variables. Add
155    /// variables with [`Env::with_var`].
156    pub fn for_test(home: impl Into<PathBuf>) -> Self {
157        Self::new_inner(Some(Paths::new(home)), HashMap::new())
158    }
159
160    /// An environment with neither a home directory nor variables. Models the
161    /// rare case where `dirs::home_dir()` returns `None`.
162    pub fn empty() -> Self {
163        Self::new_inner(None, HashMap::new())
164    }
165
166    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
167    /// Env. Each call is isolated, so parallel tests never share on-disk state
168    /// and need no lock. The default for test `App` fixtures.
169    #[cfg(test)]
170    pub fn sandboxed() -> Self {
171        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
172        let mut env = Self::for_test(dir.path());
173        env._sandbox = Some(std::sync::Arc::new(dir));
174        env
175    }
176
177    /// Builder: set a variable. Chainable, for test construction.
178    #[must_use]
179    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
180        self.vars.insert(key.into(), value.into());
181        self
182    }
183
184    /// Home-derived paths, or `None` when the home directory is unknown.
185    pub fn paths(&self) -> Option<&Paths> {
186        self.paths.as_ref()
187    }
188
189    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
190    /// expansion, which references user-chosen names.
191    pub fn var(&self, key: &str) -> Option<&str> {
192        self.vars.get(key).map(String::as_str)
193    }
194
195    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
196    pub fn vault_addr(&self) -> Option<&str> {
197        self.var("VAULT_ADDR")
198    }
199
200    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
201    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
202        match (
203            self.var("AWS_ACCESS_KEY_ID"),
204            self.var("AWS_SECRET_ACCESS_KEY"),
205        ) {
206            (Some(id), Some(secret)) => Some((id, secret)),
207            _ => None,
208        }
209    }
210
211    /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials.
212    pub fn aws_session_token(&self) -> Option<&str> {
213        self.var("AWS_SESSION_TOKEN")
214    }
215
216    /// `PURPLE_TOKEN`, the self-invocation auth token.
217    pub fn purple_token(&self) -> Option<&str> {
218        self.var("PURPLE_TOKEN")
219    }
220
221    /// True when `NO_COLOR` is present (any value), per the no-color convention.
222    pub fn no_color(&self) -> bool {
223        self.vars.contains_key("NO_COLOR")
224    }
225
226    /// `COLORTERM`.
227    pub fn colorterm(&self) -> Option<&str> {
228        self.var("COLORTERM")
229    }
230
231    /// `TERM_PROGRAM`.
232    pub fn term_program(&self) -> Option<&str> {
233        self.var("TERM_PROGRAM")
234    }
235
236    /// `TERM`.
237    pub fn term(&self) -> Option<&str> {
238        self.var("TERM")
239    }
240
241    /// True when running inside tmux (`TMUX` is set).
242    pub fn in_tmux(&self) -> bool {
243        self.vars.contains_key("TMUX")
244    }
245
246    /// Proxy-related variable names that are set to a non-empty value, in a
247    /// stable order. Drives the startup banner's proxy summary.
248    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
249        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
250            .into_iter()
251            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
252            .collect()
253    }
254
255    /// Build a `Command` for `program` whose environment is exactly this Env's
256    /// snapshot. In production the snapshot is the full process environment
257    /// captured at startup, so the subprocess sees the same env it would have
258    /// inherited. Tests construct an `Env` with only the vars they care about
259    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
260    /// behaviour are controlled without mutating the process-global env (no
261    /// `unsafe set_var`, no lock).
262    pub fn command(&self, program: &str) -> std::process::Command {
263        let mut cmd = std::process::Command::new(program);
264        cmd.env_clear();
265        cmd.envs(&self.vars);
266        cmd
267    }
268}
269
270// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
271// VAULT_ADDR). Shows the home directory and the set of variable names only.
272impl std::fmt::Debug for Env {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
275        names.sort_unstable();
276        f.debug_struct("Env")
277            .field("home", &self.paths.as_ref().map(Paths::home))
278            .field("var_names", &names)
279            .finish()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn paths_derive_under_purple_and_ssh() {
289        let p = Paths::new("/home/u");
290        assert_eq!(p.purple_dir(), PathBuf::from("/home/u/.purple"));
291        assert_eq!(
292            p.preferences(),
293            PathBuf::from("/home/u/.purple/preferences")
294        );
295        assert_eq!(p.snippets_dir(), PathBuf::from("/home/u/.purple/snippets"));
296        assert_eq!(
297            p.container_cache(),
298            PathBuf::from("/home/u/.purple/container_cache.jsonl")
299        );
300        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
301        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
302        assert_eq!(
303            p.last_version_check(),
304            PathBuf::from("/home/u/.purple/last_version_check")
305        );
306        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
307        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
308    }
309
310    #[test]
311    fn cert_for_uses_alias_filename() {
312        let p = Paths::new("/home/u");
313        assert_eq!(
314            p.cert_for("web-1"),
315            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
316        );
317    }
318
319    #[test]
320    fn askpass_marker_sanitises_traversal_chars() {
321        let p = Paths::new("/home/u");
322        assert_eq!(
323            p.askpass_marker("a/b\\c.d"),
324            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
325        );
326    }
327
328    #[test]
329    fn for_test_has_paths_and_no_vars() {
330        let env = Env::for_test("/tmp/x");
331        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
332        assert_eq!(env.var("ANYTHING"), None);
333        assert!(!env.no_color());
334    }
335
336    #[test]
337    fn empty_has_no_paths() {
338        let env = Env::empty();
339        assert!(env.paths().is_none());
340    }
341
342    #[test]
343    fn sandboxed_gives_isolated_existing_dirs() {
344        let a = Env::sandboxed();
345        let b = Env::sandboxed();
346        let pa = a.paths().unwrap().home().to_path_buf();
347        let pb = b.paths().unwrap().home().to_path_buf();
348        assert_ne!(pa, pb, "each sandbox is a distinct directory");
349        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
350        // Writing through the derived paths works (atomic_write creates parents).
351        let prefs = a.paths().unwrap().preferences();
352        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
353        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
354    }
355
356    #[test]
357    fn with_var_sets_typed_accessors() {
358        let env = Env::for_test("/tmp/x")
359            .with_var("VAULT_ADDR", "https://vault.example:8200")
360            .with_var("COLORTERM", "truecolor")
361            .with_var("NO_COLOR", "1")
362            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
363        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
364        assert_eq!(env.colorterm(), Some("truecolor"));
365        assert!(env.no_color());
366        assert!(env.in_tmux());
367    }
368
369    #[test]
370    fn aws_credentials_require_both_keys() {
371        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
372        assert_eq!(only_id.aws_credentials(), None);
373        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
374        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
375    }
376
377    #[test]
378    fn aws_session_token_reads_env_var() {
379        let env = Env::for_test("/tmp/x");
380        assert_eq!(env.aws_session_token(), None);
381        let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
382        assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
383    }
384
385    #[test]
386    fn aws_session_token_is_independent_of_key_pair() {
387        // Temporary credentials always arrive as a triple, but the accessor
388        // must not depend on the key pair being present.
389        let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
390        assert_eq!(env.aws_credentials(), None);
391        assert_eq!(env.aws_session_token(), Some("TOKEN"));
392    }
393
394    #[test]
395    fn active_proxy_vars_filters_empty_and_orders() {
396        let env = Env::for_test("/tmp/x")
397            .with_var("HTTPS_PROXY", "http://proxy:3128")
398            .with_var("HTTP_PROXY", "")
399            .with_var("NO_PROXY", "localhost");
400        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
401    }
402
403    #[test]
404    fn debug_redacts_secret_values() {
405        let env = Env::for_test("/tmp/x")
406            .with_var("PURPLE_TOKEN", "super-secret")
407            .with_var("VAULT_ADDR", "https://vault.example:8200");
408        let rendered = format!("{env:?}");
409        assert!(!rendered.contains("super-secret"));
410        assert!(!rendered.contains("vault.example"));
411        assert!(rendered.contains("PURPLE_TOKEN"));
412        assert!(rendered.contains("VAULT_ADDR"));
413    }
414
415    #[test]
416    fn from_process_captures_home_and_vars() {
417        // Smoke test against the real process: home is usually set, and the
418        // snapshot is internally consistent with the typed accessors.
419        let env = Env::from_process();
420        // No assertion on specific vars (CI environments differ); just verify
421        // the snapshot mechanism works end to end.
422        let _ = env.paths();
423        let _ = env.var("PATH");
424    }
425}