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 last_version_check(&self) -> PathBuf {
61 self.purple_dir().join("last_version_check")
62 }
63
64 pub fn certs_dir(&self) -> PathBuf {
66 self.purple_dir().join("certs")
67 }
68
69 pub fn cert_for(&self, alias: &str) -> PathBuf {
71 self.certs_dir().join(format!("{alias}-cert.pub"))
72 }
73
74 pub fn ssh_dir(&self) -> PathBuf {
76 self.home.join(".ssh")
77 }
78
79 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#[derive(Clone)]
92pub struct Env {
93 paths: Option<Paths>,
94 vars: HashMap<String, String>,
95 #[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 pub fn from_process() -> Self {
116 Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
117 }
118
119 pub fn for_test(home: impl Into<PathBuf>) -> Self {
122 Self::new_inner(Some(Paths::new(home)), HashMap::new())
123 }
124
125 pub fn empty() -> Self {
128 Self::new_inner(None, HashMap::new())
129 }
130
131 #[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 #[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 pub fn paths(&self) -> Option<&Paths> {
151 self.paths.as_ref()
152 }
153
154 pub fn var(&self, key: &str) -> Option<&str> {
157 self.vars.get(key).map(String::as_str)
158 }
159
160 pub fn vault_addr(&self) -> Option<&str> {
162 self.var("VAULT_ADDR")
163 }
164
165 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 pub fn purple_token(&self) -> Option<&str> {
178 self.var("PURPLE_TOKEN")
179 }
180
181 pub fn no_color(&self) -> bool {
183 self.vars.contains_key("NO_COLOR")
184 }
185
186 pub fn colorterm(&self) -> Option<&str> {
188 self.var("COLORTERM")
189 }
190
191 pub fn term_program(&self) -> Option<&str> {
193 self.var("TERM_PROGRAM")
194 }
195
196 pub fn term(&self) -> Option<&str> {
198 self.var("TERM")
199 }
200
201 pub fn in_tmux(&self) -> bool {
203 self.vars.contains_key("TMUX")
204 }
205
206 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 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
230impl 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 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 let env = Env::from_process();
363 let _ = env.paths();
366 let _ = env.var("PATH");
367 }
368}