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 purple_token(&self) -> Option<&str> {
213 self.var("PURPLE_TOKEN")
214 }
215
216 pub fn no_color(&self) -> bool {
218 self.vars.contains_key("NO_COLOR")
219 }
220
221 pub fn colorterm(&self) -> Option<&str> {
223 self.var("COLORTERM")
224 }
225
226 pub fn term_program(&self) -> Option<&str> {
228 self.var("TERM_PROGRAM")
229 }
230
231 pub fn term(&self) -> Option<&str> {
233 self.var("TERM")
234 }
235
236 pub fn in_tmux(&self) -> bool {
238 self.vars.contains_key("TMUX")
239 }
240
241 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 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
265impl 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 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 let env = Env::from_process();
398 let _ = env.paths();
401 let _ = env.var("PATH");
402 }
403}