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    /// `PURPLE_TOKEN`, the self-invocation auth token.
212    pub fn purple_token(&self) -> Option<&str> {
213        self.var("PURPLE_TOKEN")
214    }
215
216    /// True when `NO_COLOR` is present (any value), per the no-color convention.
217    pub fn no_color(&self) -> bool {
218        self.vars.contains_key("NO_COLOR")
219    }
220
221    /// `COLORTERM`.
222    pub fn colorterm(&self) -> Option<&str> {
223        self.var("COLORTERM")
224    }
225
226    /// `TERM_PROGRAM`.
227    pub fn term_program(&self) -> Option<&str> {
228        self.var("TERM_PROGRAM")
229    }
230
231    /// `TERM`.
232    pub fn term(&self) -> Option<&str> {
233        self.var("TERM")
234    }
235
236    /// True when running inside tmux (`TMUX` is set).
237    pub fn in_tmux(&self) -> bool {
238        self.vars.contains_key("TMUX")
239    }
240
241    /// Proxy-related variable names that are set to a non-empty value, in a
242    /// stable order. Drives the startup banner's proxy summary.
243    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
244        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
245            .into_iter()
246            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
247            .collect()
248    }
249
250    /// Build a `Command` for `program` whose environment is exactly this Env's
251    /// snapshot. In production the snapshot is the full process environment
252    /// captured at startup, so the subprocess sees the same env it would have
253    /// inherited. Tests construct an `Env` with only the vars they care about
254    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
255    /// behaviour are controlled without mutating the process-global env (no
256    /// `unsafe set_var`, no lock).
257    pub fn command(&self, program: &str) -> std::process::Command {
258        let mut cmd = std::process::Command::new(program);
259        cmd.env_clear();
260        cmd.envs(&self.vars);
261        cmd
262    }
263}
264
265// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
266// VAULT_ADDR). Shows the home directory and the set of variable names only.
267impl std::fmt::Debug for Env {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
270        names.sort_unstable();
271        f.debug_struct("Env")
272            .field("home", &self.paths.as_ref().map(Paths::home))
273            .field("var_names", &names)
274            .finish()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn paths_derive_under_purple_and_ssh() {
284        let p = Paths::new("/home/u");
285        assert_eq!(p.purple_dir(), PathBuf::from("/home/u/.purple"));
286        assert_eq!(
287            p.preferences(),
288            PathBuf::from("/home/u/.purple/preferences")
289        );
290        assert_eq!(p.snippets_dir(), PathBuf::from("/home/u/.purple/snippets"));
291        assert_eq!(
292            p.container_cache(),
293            PathBuf::from("/home/u/.purple/container_cache.jsonl")
294        );
295        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
296        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
297        assert_eq!(
298            p.last_version_check(),
299            PathBuf::from("/home/u/.purple/last_version_check")
300        );
301        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
302        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
303    }
304
305    #[test]
306    fn cert_for_uses_alias_filename() {
307        let p = Paths::new("/home/u");
308        assert_eq!(
309            p.cert_for("web-1"),
310            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
311        );
312    }
313
314    #[test]
315    fn askpass_marker_sanitises_traversal_chars() {
316        let p = Paths::new("/home/u");
317        assert_eq!(
318            p.askpass_marker("a/b\\c.d"),
319            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
320        );
321    }
322
323    #[test]
324    fn for_test_has_paths_and_no_vars() {
325        let env = Env::for_test("/tmp/x");
326        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
327        assert_eq!(env.var("ANYTHING"), None);
328        assert!(!env.no_color());
329    }
330
331    #[test]
332    fn empty_has_no_paths() {
333        let env = Env::empty();
334        assert!(env.paths().is_none());
335    }
336
337    #[test]
338    fn sandboxed_gives_isolated_existing_dirs() {
339        let a = Env::sandboxed();
340        let b = Env::sandboxed();
341        let pa = a.paths().unwrap().home().to_path_buf();
342        let pb = b.paths().unwrap().home().to_path_buf();
343        assert_ne!(pa, pb, "each sandbox is a distinct directory");
344        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
345        // Writing through the derived paths works (atomic_write creates parents).
346        let prefs = a.paths().unwrap().preferences();
347        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
348        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
349    }
350
351    #[test]
352    fn with_var_sets_typed_accessors() {
353        let env = Env::for_test("/tmp/x")
354            .with_var("VAULT_ADDR", "https://vault.example:8200")
355            .with_var("COLORTERM", "truecolor")
356            .with_var("NO_COLOR", "1")
357            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
358        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
359        assert_eq!(env.colorterm(), Some("truecolor"));
360        assert!(env.no_color());
361        assert!(env.in_tmux());
362    }
363
364    #[test]
365    fn aws_credentials_require_both_keys() {
366        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
367        assert_eq!(only_id.aws_credentials(), None);
368        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
369        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
370    }
371
372    #[test]
373    fn active_proxy_vars_filters_empty_and_orders() {
374        let env = Env::for_test("/tmp/x")
375            .with_var("HTTPS_PROXY", "http://proxy:3128")
376            .with_var("HTTP_PROXY", "")
377            .with_var("NO_PROXY", "localhost");
378        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
379    }
380
381    #[test]
382    fn debug_redacts_secret_values() {
383        let env = Env::for_test("/tmp/x")
384            .with_var("PURPLE_TOKEN", "super-secret")
385            .with_var("VAULT_ADDR", "https://vault.example:8200");
386        let rendered = format!("{env:?}");
387        assert!(!rendered.contains("super-secret"));
388        assert!(!rendered.contains("vault.example"));
389        assert!(rendered.contains("PURPLE_TOKEN"));
390        assert!(rendered.contains("VAULT_ADDR"));
391    }
392
393    #[test]
394    fn from_process_captures_home_and_vars() {
395        // Smoke test against the real process: home is usually set, and the
396        // snapshot is internally consistent with the typed accessors.
397        let env = Env::from_process();
398        // No assertion on specific vars (CI environments differ); just verify
399        // the snapshot mechanism works end to end.
400        let _ = env.paths();
401        let _ = env.var("PATH");
402    }
403}