1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Category {
17 Config,
20 Data,
23 State,
25 Cache,
27}
28
29impl Category {
30 pub const ALL: [Category; 4] = [
31 Category::Config,
32 Category::Data,
33 Category::State,
34 Category::Cache,
35 ];
36
37 pub fn purple_var(self) -> &'static str {
39 match self {
40 Category::Config => "PURPLE_CONFIG_DIR",
41 Category::Data => "PURPLE_DATA_DIR",
42 Category::State => "PURPLE_STATE_DIR",
43 Category::Cache => "PURPLE_CACHE_DIR",
44 }
45 }
46
47 pub fn xdg_var(self) -> &'static str {
49 match self {
50 Category::Config => "XDG_CONFIG_HOME",
51 Category::Data => "XDG_DATA_HOME",
52 Category::State => "XDG_STATE_HOME",
53 Category::Cache => "XDG_CACHE_HOME",
54 }
55 }
56
57 pub fn name(self) -> &'static str {
59 match self {
60 Category::Config => "config",
61 Category::Data => "data",
62 Category::State => "state",
63 Category::Cache => "cache",
64 }
65 }
66}
67
68const XDG_APP_DIR: &str = "purple";
70
71#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Paths {
80 home: PathBuf,
81 config_dir: PathBuf,
82 data_dir: PathBuf,
83 state_dir: PathBuf,
84 cache_dir: PathBuf,
85}
86
87impl Paths {
88 pub fn new(home: impl Into<PathBuf>) -> Self {
90 let home = home.into();
91 let legacy = home.join(".purple");
92 Self {
93 home,
94 config_dir: legacy.clone(),
95 data_dir: legacy.clone(),
96 state_dir: legacy.clone(),
97 cache_dir: legacy,
98 }
99 }
100
101 pub fn resolve(home: impl Into<PathBuf>, var: impl Fn(&str) -> Option<String>) -> Self {
107 let mut paths = Self::new(home);
108 for category in Category::ALL {
109 let dir = resolve_category(&paths.home, category, &var);
110 *paths.dir_mut(category) = dir;
111 }
112 paths
113 }
114
115 fn dir_mut(&mut self, category: Category) -> &mut PathBuf {
116 match category {
117 Category::Config => &mut self.config_dir,
118 Category::Data => &mut self.data_dir,
119 Category::State => &mut self.state_dir,
120 Category::Cache => &mut self.cache_dir,
121 }
122 }
123
124 pub fn home(&self) -> &Path {
125 &self.home
126 }
127
128 pub fn legacy_dir(&self) -> PathBuf {
131 self.home.join(".purple")
132 }
133
134 pub fn dir(&self, category: Category) -> &Path {
136 match category {
137 Category::Config => &self.config_dir,
138 Category::Data => &self.data_dir,
139 Category::State => &self.state_dir,
140 Category::Cache => &self.cache_dir,
141 }
142 }
143
144 pub fn config_dir(&self) -> &Path {
146 &self.config_dir
147 }
148
149 pub fn data_dir(&self) -> &Path {
151 &self.data_dir
152 }
153
154 pub fn state_dir(&self) -> &Path {
156 &self.state_dir
157 }
158
159 pub fn cache_dir(&self) -> &Path {
161 &self.cache_dir
162 }
163
164 pub fn is_split(&self) -> bool {
166 let legacy = self.legacy_dir();
167 Category::ALL.iter().any(|c| self.dir(*c) != legacy)
168 }
169
170 pub fn preferences(&self) -> PathBuf {
172 self.config_dir.join("preferences")
173 }
174
175 pub fn snippets_file(&self) -> PathBuf {
177 self.config_dir.join("snippets")
178 }
179
180 pub fn providers_config(&self) -> PathBuf {
182 self.config_dir.join("providers")
183 }
184
185 pub fn themes_dir(&self) -> PathBuf {
187 self.config_dir.join("themes")
188 }
189
190 pub fn certs_dir(&self) -> PathBuf {
192 self.data_dir.join("certs")
193 }
194
195 pub fn cert_for(&self, alias: &str) -> PathBuf {
197 self.certs_dir().join(format!("{alias}-cert.pub"))
198 }
199
200 pub fn config_original(&self) -> PathBuf {
203 self.data_dir.join("config.original")
204 }
205
206 pub fn history(&self) -> PathBuf {
208 self.state_dir.join("history.tsv")
209 }
210
211 pub fn recents(&self) -> PathBuf {
213 self.state_dir.join("recents.json")
214 }
215
216 pub fn key_activity(&self) -> PathBuf {
218 self.state_dir.join("key_activity.json")
219 }
220
221 pub fn snippet_runs(&self) -> PathBuf {
223 self.state_dir.join("snippet_runs.json")
224 }
225
226 pub fn sync_history(&self) -> PathBuf {
228 self.state_dir.join("sync_history.tsv")
229 }
230
231 pub fn log_file(&self) -> PathBuf {
233 self.state_dir.join("purple.log")
234 }
235
236 pub fn mcp_audit_log(&self) -> PathBuf {
238 self.state_dir.join("mcp-audit.log")
239 }
240
241 pub fn askpass_marker(&self, alias: &str) -> PathBuf {
244 let safe = alias.replace(['/', '\\', '.'], "_");
245 self.state_dir.join(format!(".askpass_{safe}"))
246 }
247
248 pub fn askpass_withheld_marker(&self, alias: &str) -> PathBuf {
254 self.state_dir.join(format!(
255 "{}{}",
256 crate::askpass::WITHHELD_MARKER_PREFIX,
257 encode_marker_alias(alias)
258 ))
259 }
260
261 pub fn askpass_pin_request_marker(&self, flow: &str, alias: &str) -> PathBuf {
265 self.state_dir.join(format!(
266 "{}request.{flow}.{}",
267 crate::security_key::PIN_MARKER_PREFIX,
268 encode_marker_alias(alias)
269 ))
270 }
271
272 pub fn askpass_pin_used_marker(&self, alias: &str) -> PathBuf {
275 self.state_dir.join(format!(
276 "{}used.{}",
277 crate::security_key::PIN_MARKER_PREFIX,
278 encode_marker_alias(alias)
279 ))
280 }
281
282 pub fn askpass_pin_refused_marker(&self, key_digest: &str) -> PathBuf {
285 self.state_dir.join(format!(
286 "{}refused.{key_digest}",
287 crate::security_key::PIN_MARKER_PREFIX
288 ))
289 }
290
291 pub fn askpass_pin_last_marker(&self, key_digest: &str) -> PathBuf {
294 self.state_dir.join(format!(
295 "{}last.{key_digest}",
296 crate::security_key::PIN_MARKER_PREFIX
297 ))
298 }
299
300 pub fn askpass_pin_lock_marker(&self, key_digest: &str) -> PathBuf {
302 self.state_dir.join(format!(
303 "{}lock.{key_digest}",
304 crate::security_key::PIN_MARKER_PREFIX
305 ))
306 }
307
308 pub fn container_cache(&self) -> PathBuf {
310 self.cache_dir.join("container_cache.jsonl")
311 }
312
313 pub fn last_version_check(&self) -> PathBuf {
315 self.cache_dir.join("last_version_check")
316 }
317
318 pub fn aws_credentials_file(&self) -> PathBuf {
320 self.home.join(".aws").join("credentials")
321 }
322
323 pub fn aws_config_file(&self) -> PathBuf {
327 self.home.join(".aws").join("config")
328 }
329
330 pub fn ssh_dir(&self) -> PathBuf {
332 self.home.join(".ssh")
333 }
334
335 pub fn abbreviate_home(&self, path: &Path) -> String {
337 match path.strip_prefix(&self.home) {
338 Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
339 _ => path.display().to_string(),
340 }
341 }
342}
343
344fn encode_marker_alias(alias: &str) -> String {
348 let mut safe = String::with_capacity(alias.len());
349 for c in alias.chars() {
350 match c {
351 '%' => safe.push_str("%25"),
352 '/' => safe.push_str("%2f"),
353 '\\' => safe.push_str("%5c"),
354 '.' => safe.push_str("%2e"),
355 other => safe.push(other),
356 }
357 }
358 safe
359}
360
361fn resolve_category(
364 home: &Path,
365 category: Category,
366 var: &impl Fn(&str) -> Option<String>,
367) -> PathBuf {
368 if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
369 return expand_home(home, &explicit);
370 }
371 if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
372 let base = PathBuf::from(base);
373 if base.is_absolute() {
374 return base.join(XDG_APP_DIR);
375 }
376 }
377 home.join(".purple")
378}
379
380fn expand_home(home: &Path, value: &str) -> PathBuf {
383 if ["~", "$HOME", "${HOME}"].contains(&value) {
384 return home.to_path_buf();
385 }
386 for prefix in ["~/", "$HOME/", "${HOME}/"] {
387 if let Some(rest) = value.strip_prefix(prefix) {
388 return home.join(rest);
389 }
390 }
391 PathBuf::from(value)
392}
393
394#[derive(Clone)]
399pub struct Env {
400 paths: Option<Paths>,
401 vars: HashMap<String, String>,
402 #[cfg(test)]
406 _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
407}
408
409impl Env {
410 fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
411 Self {
412 paths,
413 vars,
414 #[cfg(test)]
415 _sandbox: None,
416 }
417 }
418
419 pub fn from_process() -> Self {
424 let vars: HashMap<String, String> = std::env::vars().collect();
425 let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
426 Self::new_inner(paths, vars)
427 }
428
429 pub fn for_test(home: impl Into<PathBuf>) -> Self {
433 Self::new_inner(Some(Paths::new(home)), HashMap::new())
434 }
435
436 pub fn empty() -> Self {
439 Self::new_inner(None, HashMap::new())
440 }
441
442 #[cfg(test)]
446 pub fn sandboxed() -> Self {
447 let dir = tempfile::tempdir().expect("create test sandbox tempdir");
448 let mut env = Self::for_test(dir.path());
449 env._sandbox = Some(std::sync::Arc::new(dir));
450 env
451 }
452
453 #[must_use]
455 pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
456 self.vars.insert(key.into(), value.into());
457 self
458 }
459
460 #[must_use]
463 pub fn with_paths(mut self, paths: Paths) -> Self {
464 self.paths = Some(paths);
465 self
466 }
467
468 pub fn paths(&self) -> Option<&Paths> {
470 self.paths.as_ref()
471 }
472
473 pub fn var(&self, key: &str) -> Option<&str> {
476 self.vars.get(key).map(String::as_str)
477 }
478
479 pub fn vault_addr(&self) -> Option<&str> {
481 self.var("VAULT_ADDR")
482 }
483
484 pub fn aws_credentials(&self) -> Option<(&str, &str)> {
486 match (
487 self.var("AWS_ACCESS_KEY_ID"),
488 self.var("AWS_SECRET_ACCESS_KEY"),
489 ) {
490 (Some(id), Some(secret)) => Some((id, secret)),
491 _ => None,
492 }
493 }
494
495 pub fn aws_session_token(&self) -> Option<&str> {
497 self.var("AWS_SESSION_TOKEN")
498 }
499
500 pub fn aws_credentials_file(&self) -> Option<PathBuf> {
503 self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
504 }
505
506 pub fn aws_config_file(&self) -> Option<PathBuf> {
509 self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
510 }
511
512 fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
521 let Some(raw) = self.var(var) else {
522 return self.paths().map(default);
523 };
524 let raw = raw.trim();
525 if raw.is_empty() {
526 return None;
527 }
528 match self.paths() {
529 Some(paths) => Some(expand_home(paths.home(), raw)),
530 None => Some(PathBuf::from(raw)),
533 }
534 }
535
536 pub fn purple_token(&self) -> Option<&str> {
540 self.var("PURPLE_TOKEN")
541 .filter(|token| !token.trim().is_empty())
542 }
543
544 pub fn no_color(&self) -> bool {
546 self.vars.contains_key("NO_COLOR")
547 }
548
549 pub fn colorterm(&self) -> Option<&str> {
551 self.var("COLORTERM")
552 }
553
554 pub fn term_program(&self) -> Option<&str> {
556 self.var("TERM_PROGRAM")
557 }
558
559 pub fn term(&self) -> Option<&str> {
561 self.var("TERM")
562 }
563
564 pub fn in_tmux(&self) -> bool {
566 self.vars.contains_key("TMUX")
567 }
568
569 pub fn active_proxy_vars(&self) -> Vec<&'static str> {
572 ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
573 .into_iter()
574 .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
575 .collect()
576 }
577
578 pub fn command(&self, program: &str) -> std::process::Command {
586 let mut cmd = std::process::Command::new(program);
587 cmd.env_clear();
588 cmd.envs(&self.vars);
589 cmd
590 }
591}
592
593impl std::fmt::Debug for Env {
596 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597 let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
598 names.sort_unstable();
599 f.debug_struct("Env")
600 .field("home", &self.paths.as_ref().map(Paths::home))
601 .field("var_names", &names)
602 .finish()
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
611 move |k| {
612 pairs
613 .iter()
614 .find(|(key, _)| *key == k)
615 .map(|(_, v)| (*v).to_string())
616 }
617 }
618
619 #[test]
620 fn paths_derive_under_purple_and_ssh() {
621 let p = Paths::new("/home/u");
622 assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
623 assert_eq!(
624 p.preferences(),
625 PathBuf::from("/home/u/.purple/preferences")
626 );
627 assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
628 assert_eq!(
629 p.container_cache(),
630 PathBuf::from("/home/u/.purple/container_cache.jsonl")
631 );
632 assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
633 assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
634 assert_eq!(
635 p.last_version_check(),
636 PathBuf::from("/home/u/.purple/last_version_check")
637 );
638 assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
639 assert_eq!(
640 p.config_original(),
641 PathBuf::from("/home/u/.purple/config.original")
642 );
643 assert_eq!(
644 p.mcp_audit_log(),
645 PathBuf::from("/home/u/.purple/mcp-audit.log")
646 );
647 assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
648 assert!(!p.is_split());
649 }
650
651 #[test]
652 fn legacy_layout_puts_every_category_under_dot_purple() {
653 let p = Paths::new("/home/u");
654 for c in Category::ALL {
655 assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
656 }
657 }
658
659 #[test]
660 fn resolve_without_variables_is_the_legacy_layout() {
661 let resolved = Paths::resolve("/home/u", |_| None);
662 assert_eq!(resolved, Paths::new("/home/u"));
663 }
664
665 #[test]
666 fn resolve_honors_each_xdg_variable_independently() {
667 let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
668 let p = Paths::resolve("/home/u", lookup(&vars));
669 assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
670 assert_eq!(
671 p.preferences(),
672 PathBuf::from("/home/u/.config/purple/preferences")
673 );
674 assert_eq!(
675 p.themes_dir(),
676 PathBuf::from("/home/u/.config/purple/themes")
677 );
678 assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
680 assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
681 assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
682 assert!(p.is_split());
683 }
684
685 #[test]
686 fn resolve_maps_every_file_to_its_category() {
687 let vars = [
688 ("XDG_CONFIG_HOME", "/x/cfg"),
689 ("XDG_DATA_HOME", "/x/data"),
690 ("XDG_STATE_HOME", "/x/state"),
691 ("XDG_CACHE_HOME", "/x/cache"),
692 ];
693 let p = Paths::resolve("/home/u", lookup(&vars));
694 assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
695 assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
696 assert_eq!(
697 p.providers_config(),
698 PathBuf::from("/x/cfg/purple/providers")
699 );
700 assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
701 assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
702 assert_eq!(
703 p.cert_for("web"),
704 PathBuf::from("/x/data/purple/certs/web-cert.pub")
705 );
706 assert_eq!(
707 p.config_original(),
708 PathBuf::from("/x/data/purple/config.original")
709 );
710 assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
711 assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
712 assert_eq!(
713 p.key_activity(),
714 PathBuf::from("/x/state/purple/key_activity.json")
715 );
716 assert_eq!(
717 p.snippet_runs(),
718 PathBuf::from("/x/state/purple/snippet_runs.json")
719 );
720 assert_eq!(
721 p.sync_history(),
722 PathBuf::from("/x/state/purple/sync_history.tsv")
723 );
724 assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
725 assert_eq!(
726 p.mcp_audit_log(),
727 PathBuf::from("/x/state/purple/mcp-audit.log")
728 );
729 assert_eq!(
730 p.askpass_marker("a"),
731 PathBuf::from("/x/state/purple/.askpass_a")
732 );
733 assert_eq!(
734 p.container_cache(),
735 PathBuf::from("/x/cache/purple/container_cache.jsonl")
736 );
737 assert_eq!(
738 p.last_version_check(),
739 PathBuf::from("/x/cache/purple/last_version_check")
740 );
741 assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
743 assert_eq!(
744 p.aws_credentials_file(),
745 PathBuf::from("/home/u/.aws/credentials")
746 );
747 assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
748 }
749
750 #[test]
751 fn resolve_prefers_purple_override_over_xdg() {
752 let vars = [
753 ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
754 ("XDG_CONFIG_HOME", "/home/u/.config"),
755 ];
756 let p = Paths::resolve("/home/u", lookup(&vars));
757 assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
759 }
760
761 #[test]
762 fn resolve_expands_home_in_purple_overrides() {
763 for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
764 let vars = [("PURPLE_CONFIG_DIR", value)];
765 let p = Paths::resolve("/home/u", lookup(&vars));
766 assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
767 }
768 }
769
770 #[test]
771 fn resolve_treats_empty_variables_as_unset() {
772 let vars = [
773 ("PURPLE_STATE_DIR", ""),
774 ("XDG_STATE_HOME", " "),
775 ("XDG_CACHE_HOME", ""),
776 ];
777 let p = Paths::resolve("/home/u", lookup(&vars));
778 assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
779 assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
780 }
781
782 #[test]
783 fn resolve_ignores_relative_xdg_paths() {
784 let vars = [("XDG_DATA_HOME", "relative/share")];
786 let p = Paths::resolve("/home/u", lookup(&vars));
787 assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
788 }
789
790 #[test]
791 fn resolve_empty_purple_override_falls_through_to_xdg() {
792 let vars = [
793 ("PURPLE_DATA_DIR", ""),
794 ("XDG_DATA_HOME", "/home/u/.local/share"),
795 ];
796 let p = Paths::resolve("/home/u", lookup(&vars));
797 assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
798 }
799
800 #[test]
801 fn category_variable_names() {
802 assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
803 assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
804 assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
805 assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
806 assert_eq!(Category::State.name(), "state");
807 }
808
809 #[test]
810 fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
811 let p = Paths::new("/home/u");
812 assert_eq!(
813 p.abbreviate_home(&p.config_original()),
814 "~/.purple/config.original"
815 );
816 assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
817 assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
818 }
819
820 #[test]
821 fn cert_for_uses_alias_filename() {
822 let p = Paths::new("/home/u");
823 assert_eq!(
824 p.cert_for("web-1"),
825 PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
826 );
827 }
828
829 #[test]
830 fn askpass_marker_sanitises_traversal_chars() {
831 let p = Paths::new("/home/u");
832 assert_eq!(
833 p.askpass_marker("a/b\\c.d"),
834 PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
835 );
836 }
837
838 #[test]
839 fn askpass_withheld_marker_keeps_each_alias_to_itself() {
840 let p = Paths::new("/home/u");
843 assert_ne!(
844 p.askpass_withheld_marker("web.prod"),
845 p.askpass_withheld_marker("web_prod")
846 );
847 assert_eq!(
848 p.askpass_withheld_marker("web.prod"),
849 PathBuf::from("/home/u/.purple/.askpass_withheld.web%2eprod")
850 );
851 }
852
853 #[test]
854 fn askpass_withheld_marker_cannot_walk_out_of_the_state_dir() {
855 let p = Paths::new("/home/u");
856 assert_eq!(
857 p.askpass_withheld_marker("../../etc/passwd"),
858 PathBuf::from("/home/u/.purple/.askpass_withheld.%2e%2e%2f%2e%2e%2fetc%2fpasswd")
859 );
860 }
861
862 #[test]
863 fn askpass_withheld_marker_never_collides_with_a_retry_marker() {
864 let p = Paths::new("/home/u");
867 assert_ne!(
868 p.askpass_marker("withheld.x"),
869 p.askpass_withheld_marker("x")
870 );
871 }
872
873 #[test]
874 fn askpass_pin_markers_cannot_walk_out_of_the_state_dir() {
875 let p = Paths::new("/home/u");
876 assert_eq!(
877 p.askpass_pin_request_marker("tunnel", "../x"),
878 PathBuf::from("/home/u/.purple/.askpass_pin.request.tunnel.%2e%2e%2fx")
879 );
880 assert_eq!(
881 p.askpass_pin_used_marker("web.prod"),
882 PathBuf::from("/home/u/.purple/.askpass_pin.used.web%2eprod")
883 );
884 assert_eq!(
885 p.askpass_pin_refused_marker("abc123"),
886 PathBuf::from("/home/u/.purple/.askpass_pin.refused.abc123")
887 );
888 assert_eq!(
889 p.askpass_pin_last_marker("abc123"),
890 PathBuf::from("/home/u/.purple/.askpass_pin.last.abc123")
891 );
892 assert_eq!(
893 p.askpass_pin_lock_marker("abc123"),
894 PathBuf::from("/home/u/.purple/.askpass_pin.lock.abc123")
895 );
896 }
897
898 #[test]
899 fn askpass_pin_markers_never_collide_with_a_retry_marker() {
900 let p = Paths::new("/home/u");
903 assert_ne!(
904 p.askpass_marker("pin.used.x"),
905 p.askpass_pin_used_marker("x")
906 );
907 }
908
909 #[test]
910 fn for_test_has_paths_and_no_vars() {
911 let env = Env::for_test("/tmp/x");
912 assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
913 assert_eq!(env.var("ANYTHING"), None);
914 assert!(!env.no_color());
915 }
916
917 #[test]
918 fn with_paths_replaces_the_layout() {
919 let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
920 let env = Env::for_test("/tmp/x").with_paths(split.clone());
921 assert_eq!(env.paths(), Some(&split));
922 assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
923 }
924
925 #[test]
926 fn empty_has_no_paths() {
927 let env = Env::empty();
928 assert!(env.paths().is_none());
929 }
930
931 #[test]
932 fn sandboxed_gives_isolated_existing_dirs() {
933 let a = Env::sandboxed();
934 let b = Env::sandboxed();
935 let pa = a.paths().unwrap().home().to_path_buf();
936 let pb = b.paths().unwrap().home().to_path_buf();
937 assert_ne!(pa, pb, "each sandbox is a distinct directory");
938 assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
939 let prefs = a.paths().unwrap().preferences();
941 crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
942 assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
943 }
944
945 #[test]
946 fn with_var_sets_typed_accessors() {
947 let env = Env::for_test("/tmp/x")
948 .with_var("VAULT_ADDR", "https://vault.example:8200")
949 .with_var("COLORTERM", "truecolor")
950 .with_var("NO_COLOR", "1")
951 .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
952 assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
953 assert_eq!(env.colorterm(), Some("truecolor"));
954 assert!(env.no_color());
955 assert!(env.in_tmux());
956 }
957
958 #[test]
959 fn aws_credentials_require_both_keys() {
960 let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
961 assert_eq!(only_id.aws_credentials(), None);
962 let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
963 assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
964 }
965
966 #[test]
967 fn purple_token_treats_a_blank_variable_as_absent() {
968 assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
972 for blank in ["", " "] {
973 assert_eq!(
974 Env::for_test("/tmp/x")
975 .with_var("PURPLE_TOKEN", blank)
976 .purple_token(),
977 None,
978 "a blank PURPLE_TOKEN must read as absent"
979 );
980 }
981 assert_eq!(
982 Env::for_test("/tmp/x")
983 .with_var("PURPLE_TOKEN", "tok")
984 .purple_token(),
985 Some("tok")
986 );
987 }
988
989 #[test]
990 fn aws_session_token_reads_env_var() {
991 let env = Env::for_test("/tmp/x");
992 assert_eq!(env.aws_session_token(), None);
993 let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
994 assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
995 }
996
997 #[test]
998 fn aws_session_token_is_independent_of_key_pair() {
999 let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
1002 assert_eq!(env.aws_credentials(), None);
1003 assert_eq!(env.aws_session_token(), Some("TOKEN"));
1004 }
1005
1006 #[test]
1007 fn active_proxy_vars_filters_empty_and_orders() {
1008 let env = Env::for_test("/tmp/x")
1009 .with_var("HTTPS_PROXY", "http://proxy:3128")
1010 .with_var("HTTP_PROXY", "")
1011 .with_var("NO_PROXY", "localhost");
1012 assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
1013 }
1014
1015 #[test]
1016 fn debug_redacts_secret_values() {
1017 let env = Env::for_test("/tmp/x")
1018 .with_var("PURPLE_TOKEN", "super-secret")
1019 .with_var("VAULT_ADDR", "https://vault.example:8200");
1020 let rendered = format!("{env:?}");
1021 assert!(!rendered.contains("super-secret"));
1022 assert!(!rendered.contains("vault.example"));
1023 assert!(rendered.contains("PURPLE_TOKEN"));
1024 assert!(rendered.contains("VAULT_ADDR"));
1025 }
1026
1027 #[test]
1028 fn from_process_captures_home_and_vars() {
1029 let env = Env::from_process();
1032 let _ = env.paths();
1035 let _ = env.var("PATH");
1036 }
1037 #[test]
1038 fn the_aws_files_default_to_the_dot_aws_directory() {
1039 let env = Env::for_test("/home/u");
1040 assert_eq!(
1041 env.aws_credentials_file(),
1042 Some(PathBuf::from("/home/u/.aws/credentials"))
1043 );
1044 assert_eq!(
1045 env.aws_config_file(),
1046 Some(PathBuf::from("/home/u/.aws/config"))
1047 );
1048 }
1049
1050 #[test]
1051 fn an_absolute_override_relocates_the_aws_file() {
1052 let env = Env::for_test("/home/u")
1053 .with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
1054 .with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
1055 assert_eq!(
1056 env.aws_credentials_file(),
1057 Some(PathBuf::from("/etc/aws/creds"))
1058 );
1059 assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
1060 }
1061
1062 #[test]
1063 fn a_home_relative_override_expands_against_the_home_directory() {
1064 let env = Env::for_test("/home/u")
1067 .with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
1068 .with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
1069 assert_eq!(
1070 env.aws_credentials_file(),
1071 Some(PathBuf::from("/home/u/work/creds"))
1072 );
1073 assert_eq!(
1074 env.aws_config_file(),
1075 Some(PathBuf::from("/home/u/work/conf"))
1076 );
1077 }
1078
1079 #[test]
1080 fn a_bare_tilde_override_is_the_home_directory() {
1081 let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
1082 assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
1083 }
1084
1085 #[test]
1086 fn an_exported_but_blank_override_means_no_file() {
1087 let env = Env::for_test("/home/u")
1090 .with_var("AWS_SHARED_CREDENTIALS_FILE", "")
1091 .with_var("AWS_CONFIG_FILE", " ");
1092 assert_eq!(env.aws_credentials_file(), None);
1093 assert_eq!(env.aws_config_file(), None);
1094 }
1095
1096 #[test]
1097 fn without_a_home_directory_only_an_override_names_an_aws_file() {
1098 assert_eq!(Env::empty().aws_credentials_file(), None);
1099 assert_eq!(
1100 Env::empty()
1101 .with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
1102 .aws_config_file(),
1103 Some(PathBuf::from("/etc/aws/conf"))
1104 );
1105 }
1106}