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