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