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 snippet_runs(&self) -> PathBuf {
66 self.purple_dir().join("snippet_runs.json")
67 }
68
69 pub fn sync_history(&self) -> PathBuf {
71 self.purple_dir().join("sync_history.tsv")
72 }
73
74 pub fn recents(&self) -> PathBuf {
76 self.purple_dir().join("recents.json")
77 }
78
79 pub fn providers_config(&self) -> PathBuf {
81 self.purple_dir().join("providers")
82 }
83
84 pub fn themes_dir(&self) -> PathBuf {
86 self.purple_dir().join("themes")
87 }
88
89 pub fn aws_credentials_file(&self) -> PathBuf {
91 self.home.join(".aws").join("credentials")
92 }
93
94 pub fn last_version_check(&self) -> PathBuf {
96 self.purple_dir().join("last_version_check")
97 }
98
99 pub fn certs_dir(&self) -> PathBuf {
101 self.purple_dir().join("certs")
102 }
103
104 pub fn cert_for(&self, alias: &str) -> PathBuf {
106 self.certs_dir().join(format!("{alias}-cert.pub"))
107 }
108
109 pub fn ssh_dir(&self) -> PathBuf {
111 self.home.join(".ssh")
112 }
113
114 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#[derive(Clone)]
127pub struct Env {
128 paths: Option<Paths>,
129 vars: HashMap<String, String>,
130 #[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 pub fn from_process() -> Self {
151 Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
152 }
153
154 pub fn for_test(home: impl Into<PathBuf>) -> Self {
157 Self::new_inner(Some(Paths::new(home)), HashMap::new())
158 }
159
160 pub fn empty() -> Self {
163 Self::new_inner(None, HashMap::new())
164 }
165
166 #[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 #[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 pub fn paths(&self) -> Option<&Paths> {
186 self.paths.as_ref()
187 }
188
189 pub fn var(&self, key: &str) -> Option<&str> {
192 self.vars.get(key).map(String::as_str)
193 }
194
195 pub fn vault_addr(&self) -> Option<&str> {
197 self.var("VAULT_ADDR")
198 }
199
200 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 pub fn aws_session_token(&self) -> Option<&str> {
213 self.var("AWS_SESSION_TOKEN")
214 }
215
216 pub fn purple_token(&self) -> Option<&str> {
218 self.var("PURPLE_TOKEN")
219 }
220
221 pub fn no_color(&self) -> bool {
223 self.vars.contains_key("NO_COLOR")
224 }
225
226 pub fn colorterm(&self) -> Option<&str> {
228 self.var("COLORTERM")
229 }
230
231 pub fn term_program(&self) -> Option<&str> {
233 self.var("TERM_PROGRAM")
234 }
235
236 pub fn term(&self) -> Option<&str> {
238 self.var("TERM")
239 }
240
241 pub fn in_tmux(&self) -> bool {
243 self.vars.contains_key("TMUX")
244 }
245
246 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 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
270impl 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 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 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 let env = Env::from_process();
420 let _ = env.paths();
423 let _ = env.var("PATH");
424 }
425}