purple_ssh/runtime/
env.rs1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[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 pub fn purple_dir(&self) -> PathBuf {
31 self.home.join(".purple")
32 }
33
34 pub fn preferences(&self) -> PathBuf {
36 self.purple_dir().join("preferences")
37 }
38
39 pub fn snippets_dir(&self) -> PathBuf {
41 self.purple_dir().join("snippets")
42 }
43
44 pub fn container_cache(&self) -> PathBuf {
46 self.purple_dir().join("container_cache.jsonl")
47 }
48
49 pub fn log_file(&self) -> PathBuf {
51 self.purple_dir().join("purple.log")
52 }
53
54 pub fn history(&self) -> PathBuf {
56 self.purple_dir().join("history.tsv")
57 }
58
59 pub fn key_activity(&self) -> PathBuf {
61 self.purple_dir().join("key_activity.json")
62 }
63
64 pub fn sync_history(&self) -> PathBuf {
66 self.purple_dir().join("sync_history.tsv")
67 }
68
69 pub fn recents(&self) -> PathBuf {
71 self.purple_dir().join("recents.json")
72 }
73
74 pub fn providers_config(&self) -> PathBuf {
76 self.purple_dir().join("providers")
77 }
78
79 pub fn themes_dir(&self) -> PathBuf {
81 self.purple_dir().join("themes")
82 }
83
84 pub fn aws_credentials_file(&self) -> PathBuf {
86 self.home.join(".aws").join("credentials")
87 }
88
89 pub fn last_version_check(&self) -> PathBuf {
91 self.purple_dir().join("last_version_check")
92 }
93
94 pub fn certs_dir(&self) -> PathBuf {
96 self.purple_dir().join("certs")
97 }
98
99 pub fn cert_for(&self, alias: &str) -> PathBuf {
101 self.certs_dir().join(format!("{alias}-cert.pub"))
102 }
103
104 pub fn ssh_dir(&self) -> PathBuf {
106 self.home.join(".ssh")
107 }
108
109 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#[derive(Clone)]
122pub struct Env {
123 paths: Option<Paths>,
124 vars: HashMap<String, String>,
125 #[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 pub fn from_process() -> Self {
146 Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
147 }
148
149 pub fn for_test(home: impl Into<PathBuf>) -> Self {
152 Self::new_inner(Some(Paths::new(home)), HashMap::new())
153 }
154
155 pub fn empty() -> Self {
158 Self::new_inner(None, HashMap::new())
159 }
160
161 #[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 #[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 pub fn paths(&self) -> Option<&Paths> {
181 self.paths.as_ref()
182 }
183
184 pub fn var(&self, key: &str) -> Option<&str> {
187 self.vars.get(key).map(String::as_str)
188 }
189
190 pub fn vault_addr(&self) -> Option<&str> {
192 self.var("VAULT_ADDR")
193 }
194
195 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 pub fn purple_token(&self) -> Option<&str> {
208 self.var("PURPLE_TOKEN")
209 }
210
211 pub fn no_color(&self) -> bool {
213 self.vars.contains_key("NO_COLOR")
214 }
215
216 pub fn colorterm(&self) -> Option<&str> {
218 self.var("COLORTERM")
219 }
220
221 pub fn term_program(&self) -> Option<&str> {
223 self.var("TERM_PROGRAM")
224 }
225
226 pub fn term(&self) -> Option<&str> {
228 self.var("TERM")
229 }
230
231 pub fn in_tmux(&self) -> bool {
233 self.vars.contains_key("TMUX")
234 }
235
236 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 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
260impl 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 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 let env = Env::from_process();
393 let _ = env.paths();
396 let _ = env.var("PATH");
397 }
398}