use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Config,
Data,
State,
Cache,
}
impl Category {
pub const ALL: [Category; 4] = [
Category::Config,
Category::Data,
Category::State,
Category::Cache,
];
pub fn purple_var(self) -> &'static str {
match self {
Category::Config => "PURPLE_CONFIG_DIR",
Category::Data => "PURPLE_DATA_DIR",
Category::State => "PURPLE_STATE_DIR",
Category::Cache => "PURPLE_CACHE_DIR",
}
}
pub fn xdg_var(self) -> &'static str {
match self {
Category::Config => "XDG_CONFIG_HOME",
Category::Data => "XDG_DATA_HOME",
Category::State => "XDG_STATE_HOME",
Category::Cache => "XDG_CACHE_HOME",
}
}
pub fn name(self) -> &'static str {
match self {
Category::Config => "config",
Category::Data => "data",
Category::State => "state",
Category::Cache => "cache",
}
}
}
const XDG_APP_DIR: &str = "purple";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Paths {
home: PathBuf,
config_dir: PathBuf,
data_dir: PathBuf,
state_dir: PathBuf,
cache_dir: PathBuf,
}
impl Paths {
pub fn new(home: impl Into<PathBuf>) -> Self {
let home = home.into();
let legacy = home.join(".purple");
Self {
home,
config_dir: legacy.clone(),
data_dir: legacy.clone(),
state_dir: legacy.clone(),
cache_dir: legacy,
}
}
pub fn resolve(home: impl Into<PathBuf>, var: impl Fn(&str) -> Option<String>) -> Self {
let mut paths = Self::new(home);
for category in Category::ALL {
let dir = resolve_category(&paths.home, category, &var);
*paths.dir_mut(category) = dir;
}
paths
}
fn dir_mut(&mut self, category: Category) -> &mut PathBuf {
match category {
Category::Config => &mut self.config_dir,
Category::Data => &mut self.data_dir,
Category::State => &mut self.state_dir,
Category::Cache => &mut self.cache_dir,
}
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn legacy_dir(&self) -> PathBuf {
self.home.join(".purple")
}
pub fn dir(&self, category: Category) -> &Path {
match category {
Category::Config => &self.config_dir,
Category::Data => &self.data_dir,
Category::State => &self.state_dir,
Category::Cache => &self.cache_dir,
}
}
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn is_split(&self) -> bool {
let legacy = self.legacy_dir();
Category::ALL.iter().any(|c| self.dir(*c) != legacy)
}
pub fn preferences(&self) -> PathBuf {
self.config_dir.join("preferences")
}
pub fn snippets_file(&self) -> PathBuf {
self.config_dir.join("snippets")
}
pub fn providers_config(&self) -> PathBuf {
self.config_dir.join("providers")
}
pub fn themes_dir(&self) -> PathBuf {
self.config_dir.join("themes")
}
pub fn certs_dir(&self) -> PathBuf {
self.data_dir.join("certs")
}
pub fn cert_for(&self, alias: &str) -> PathBuf {
self.certs_dir().join(format!("{alias}-cert.pub"))
}
pub fn config_original(&self) -> PathBuf {
self.data_dir.join("config.original")
}
pub fn history(&self) -> PathBuf {
self.state_dir.join("history.tsv")
}
pub fn recents(&self) -> PathBuf {
self.state_dir.join("recents.json")
}
pub fn key_activity(&self) -> PathBuf {
self.state_dir.join("key_activity.json")
}
pub fn snippet_runs(&self) -> PathBuf {
self.state_dir.join("snippet_runs.json")
}
pub fn sync_history(&self) -> PathBuf {
self.state_dir.join("sync_history.tsv")
}
pub fn log_file(&self) -> PathBuf {
self.state_dir.join("purple.log")
}
pub fn mcp_audit_log(&self) -> PathBuf {
self.state_dir.join("mcp-audit.log")
}
pub fn askpass_marker(&self, alias: &str) -> PathBuf {
let safe = alias.replace(['/', '\\', '.'], "_");
self.state_dir.join(format!(".askpass_{safe}"))
}
pub fn askpass_withheld_marker(&self, alias: &str) -> PathBuf {
self.state_dir.join(format!(
"{}{}",
crate::askpass::WITHHELD_MARKER_PREFIX,
encode_marker_alias(alias)
))
}
pub fn askpass_pin_request_marker(&self, flow: &str, alias: &str) -> PathBuf {
self.state_dir.join(format!(
"{}request.{flow}.{}",
crate::security_key::PIN_MARKER_PREFIX,
encode_marker_alias(alias)
))
}
pub fn askpass_pin_used_marker(&self, alias: &str) -> PathBuf {
self.state_dir.join(format!(
"{}used.{}",
crate::security_key::PIN_MARKER_PREFIX,
encode_marker_alias(alias)
))
}
pub fn askpass_pin_refused_marker(&self, key_digest: &str) -> PathBuf {
self.state_dir.join(format!(
"{}refused.{key_digest}",
crate::security_key::PIN_MARKER_PREFIX
))
}
pub fn askpass_pin_last_marker(&self, key_digest: &str) -> PathBuf {
self.state_dir.join(format!(
"{}last.{key_digest}",
crate::security_key::PIN_MARKER_PREFIX
))
}
pub fn askpass_pin_lock_marker(&self, key_digest: &str) -> PathBuf {
self.state_dir.join(format!(
"{}lock.{key_digest}",
crate::security_key::PIN_MARKER_PREFIX
))
}
pub fn container_cache(&self) -> PathBuf {
self.cache_dir.join("container_cache.jsonl")
}
pub fn last_version_check(&self) -> PathBuf {
self.cache_dir.join("last_version_check")
}
pub fn aws_credentials_file(&self) -> PathBuf {
self.home.join(".aws").join("credentials")
}
pub fn aws_config_file(&self) -> PathBuf {
self.home.join(".aws").join("config")
}
pub fn ssh_dir(&self) -> PathBuf {
self.home.join(".ssh")
}
pub fn abbreviate_home(&self, path: &Path) -> String {
match path.strip_prefix(&self.home) {
Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
_ => path.display().to_string(),
}
}
}
fn encode_marker_alias(alias: &str) -> String {
let mut safe = String::with_capacity(alias.len());
for c in alias.chars() {
match c {
'%' => safe.push_str("%25"),
'/' => safe.push_str("%2f"),
'\\' => safe.push_str("%5c"),
'.' => safe.push_str("%2e"),
other => safe.push(other),
}
}
safe
}
fn resolve_category(
home: &Path,
category: Category,
var: &impl Fn(&str) -> Option<String>,
) -> PathBuf {
if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
return expand_home(home, &explicit);
}
if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
let base = PathBuf::from(base);
if base.is_absolute() {
return base.join(XDG_APP_DIR);
}
}
home.join(".purple")
}
fn expand_home(home: &Path, value: &str) -> PathBuf {
if ["~", "$HOME", "${HOME}"].contains(&value) {
return home.to_path_buf();
}
for prefix in ["~/", "$HOME/", "${HOME}/"] {
if let Some(rest) = value.strip_prefix(prefix) {
return home.join(rest);
}
}
PathBuf::from(value)
}
#[derive(Clone)]
pub struct Env {
paths: Option<Paths>,
vars: HashMap<String, String>,
#[cfg(test)]
_sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
}
impl Env {
fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
Self {
paths,
vars,
#[cfg(test)]
_sandbox: None,
}
}
pub fn from_process() -> Self {
let vars: HashMap<String, String> = std::env::vars().collect();
let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
Self::new_inner(paths, vars)
}
pub fn for_test(home: impl Into<PathBuf>) -> Self {
Self::new_inner(Some(Paths::new(home)), HashMap::new())
}
pub fn empty() -> Self {
Self::new_inner(None, HashMap::new())
}
#[cfg(test)]
pub fn sandboxed() -> Self {
let dir = tempfile::tempdir().expect("create test sandbox tempdir");
let mut env = Self::for_test(dir.path());
env._sandbox = Some(std::sync::Arc::new(dir));
env
}
#[must_use]
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_paths(mut self, paths: Paths) -> Self {
self.paths = Some(paths);
self
}
pub fn paths(&self) -> Option<&Paths> {
self.paths.as_ref()
}
pub fn var(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(String::as_str)
}
pub fn vault_addr(&self) -> Option<&str> {
self.var("VAULT_ADDR")
}
pub fn aws_credentials(&self) -> Option<(&str, &str)> {
match (
self.var("AWS_ACCESS_KEY_ID"),
self.var("AWS_SECRET_ACCESS_KEY"),
) {
(Some(id), Some(secret)) => Some((id, secret)),
_ => None,
}
}
pub fn aws_session_token(&self) -> Option<&str> {
self.var("AWS_SESSION_TOKEN")
}
pub fn aws_credentials_file(&self) -> Option<PathBuf> {
self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
}
pub fn aws_config_file(&self) -> Option<PathBuf> {
self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
}
fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
let Some(raw) = self.var(var) else {
return self.paths().map(default);
};
let raw = raw.trim();
if raw.is_empty() {
return None;
}
match self.paths() {
Some(paths) => Some(expand_home(paths.home(), raw)),
None => Some(PathBuf::from(raw)),
}
}
pub fn purple_token(&self) -> Option<&str> {
self.var("PURPLE_TOKEN")
.filter(|token| !token.trim().is_empty())
}
pub fn no_color(&self) -> bool {
self.vars.contains_key("NO_COLOR")
}
pub fn colorterm(&self) -> Option<&str> {
self.var("COLORTERM")
}
pub fn term_program(&self) -> Option<&str> {
self.var("TERM_PROGRAM")
}
pub fn term(&self) -> Option<&str> {
self.var("TERM")
}
pub fn in_tmux(&self) -> bool {
self.vars.contains_key("TMUX")
}
pub fn active_proxy_vars(&self) -> Vec<&'static str> {
["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
.into_iter()
.filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
.collect()
}
pub fn command(&self, program: &str) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
cmd.env_clear();
cmd.envs(&self.vars);
cmd
}
}
impl std::fmt::Debug for Env {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
names.sort_unstable();
f.debug_struct("Env")
.field("home", &self.paths.as_ref().map(Paths::home))
.field("var_names", &names)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
pairs
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn paths_derive_under_purple_and_ssh() {
let p = Paths::new("/home/u");
assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
assert_eq!(
p.preferences(),
PathBuf::from("/home/u/.purple/preferences")
);
assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
assert_eq!(
p.container_cache(),
PathBuf::from("/home/u/.purple/container_cache.jsonl")
);
assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
assert_eq!(
p.last_version_check(),
PathBuf::from("/home/u/.purple/last_version_check")
);
assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
assert_eq!(
p.config_original(),
PathBuf::from("/home/u/.purple/config.original")
);
assert_eq!(
p.mcp_audit_log(),
PathBuf::from("/home/u/.purple/mcp-audit.log")
);
assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
assert!(!p.is_split());
}
#[test]
fn legacy_layout_puts_every_category_under_dot_purple() {
let p = Paths::new("/home/u");
for c in Category::ALL {
assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
}
}
#[test]
fn resolve_without_variables_is_the_legacy_layout() {
let resolved = Paths::resolve("/home/u", |_| None);
assert_eq!(resolved, Paths::new("/home/u"));
}
#[test]
fn resolve_honors_each_xdg_variable_independently() {
let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
assert_eq!(
p.preferences(),
PathBuf::from("/home/u/.config/purple/preferences")
);
assert_eq!(
p.themes_dir(),
PathBuf::from("/home/u/.config/purple/themes")
);
assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
assert!(p.is_split());
}
#[test]
fn resolve_maps_every_file_to_its_category() {
let vars = [
("XDG_CONFIG_HOME", "/x/cfg"),
("XDG_DATA_HOME", "/x/data"),
("XDG_STATE_HOME", "/x/state"),
("XDG_CACHE_HOME", "/x/cache"),
];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
assert_eq!(
p.providers_config(),
PathBuf::from("/x/cfg/purple/providers")
);
assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
assert_eq!(
p.cert_for("web"),
PathBuf::from("/x/data/purple/certs/web-cert.pub")
);
assert_eq!(
p.config_original(),
PathBuf::from("/x/data/purple/config.original")
);
assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
assert_eq!(
p.key_activity(),
PathBuf::from("/x/state/purple/key_activity.json")
);
assert_eq!(
p.snippet_runs(),
PathBuf::from("/x/state/purple/snippet_runs.json")
);
assert_eq!(
p.sync_history(),
PathBuf::from("/x/state/purple/sync_history.tsv")
);
assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
assert_eq!(
p.mcp_audit_log(),
PathBuf::from("/x/state/purple/mcp-audit.log")
);
assert_eq!(
p.askpass_marker("a"),
PathBuf::from("/x/state/purple/.askpass_a")
);
assert_eq!(
p.container_cache(),
PathBuf::from("/x/cache/purple/container_cache.jsonl")
);
assert_eq!(
p.last_version_check(),
PathBuf::from("/x/cache/purple/last_version_check")
);
assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
assert_eq!(
p.aws_credentials_file(),
PathBuf::from("/home/u/.aws/credentials")
);
assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
}
#[test]
fn resolve_prefers_purple_override_over_xdg() {
let vars = [
("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
("XDG_CONFIG_HOME", "/home/u/.config"),
];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
}
#[test]
fn resolve_expands_home_in_purple_overrides() {
for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
let vars = [("PURPLE_CONFIG_DIR", value)];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
}
}
#[test]
fn resolve_treats_empty_variables_as_unset() {
let vars = [
("PURPLE_STATE_DIR", ""),
("XDG_STATE_HOME", " "),
("XDG_CACHE_HOME", ""),
];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
}
#[test]
fn resolve_ignores_relative_xdg_paths() {
let vars = [("XDG_DATA_HOME", "relative/share")];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
}
#[test]
fn resolve_empty_purple_override_falls_through_to_xdg() {
let vars = [
("PURPLE_DATA_DIR", ""),
("XDG_DATA_HOME", "/home/u/.local/share"),
];
let p = Paths::resolve("/home/u", lookup(&vars));
assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
}
#[test]
fn category_variable_names() {
assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
assert_eq!(Category::State.name(), "state");
}
#[test]
fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
let p = Paths::new("/home/u");
assert_eq!(
p.abbreviate_home(&p.config_original()),
"~/.purple/config.original"
);
assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
}
#[test]
fn cert_for_uses_alias_filename() {
let p = Paths::new("/home/u");
assert_eq!(
p.cert_for("web-1"),
PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
);
}
#[test]
fn askpass_marker_sanitises_traversal_chars() {
let p = Paths::new("/home/u");
assert_eq!(
p.askpass_marker("a/b\\c.d"),
PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
);
}
#[test]
fn askpass_withheld_marker_keeps_each_alias_to_itself() {
let p = Paths::new("/home/u");
assert_ne!(
p.askpass_withheld_marker("web.prod"),
p.askpass_withheld_marker("web_prod")
);
assert_eq!(
p.askpass_withheld_marker("web.prod"),
PathBuf::from("/home/u/.purple/.askpass_withheld.web%2eprod")
);
}
#[test]
fn askpass_withheld_marker_cannot_walk_out_of_the_state_dir() {
let p = Paths::new("/home/u");
assert_eq!(
p.askpass_withheld_marker("../../etc/passwd"),
PathBuf::from("/home/u/.purple/.askpass_withheld.%2e%2e%2f%2e%2e%2fetc%2fpasswd")
);
}
#[test]
fn askpass_withheld_marker_never_collides_with_a_retry_marker() {
let p = Paths::new("/home/u");
assert_ne!(
p.askpass_marker("withheld.x"),
p.askpass_withheld_marker("x")
);
}
#[test]
fn askpass_pin_markers_cannot_walk_out_of_the_state_dir() {
let p = Paths::new("/home/u");
assert_eq!(
p.askpass_pin_request_marker("tunnel", "../x"),
PathBuf::from("/home/u/.purple/.askpass_pin.request.tunnel.%2e%2e%2fx")
);
assert_eq!(
p.askpass_pin_used_marker("web.prod"),
PathBuf::from("/home/u/.purple/.askpass_pin.used.web%2eprod")
);
assert_eq!(
p.askpass_pin_refused_marker("abc123"),
PathBuf::from("/home/u/.purple/.askpass_pin.refused.abc123")
);
assert_eq!(
p.askpass_pin_last_marker("abc123"),
PathBuf::from("/home/u/.purple/.askpass_pin.last.abc123")
);
assert_eq!(
p.askpass_pin_lock_marker("abc123"),
PathBuf::from("/home/u/.purple/.askpass_pin.lock.abc123")
);
}
#[test]
fn askpass_pin_markers_never_collide_with_a_retry_marker() {
let p = Paths::new("/home/u");
assert_ne!(
p.askpass_marker("pin.used.x"),
p.askpass_pin_used_marker("x")
);
}
#[test]
fn for_test_has_paths_and_no_vars() {
let env = Env::for_test("/tmp/x");
assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
assert_eq!(env.var("ANYTHING"), None);
assert!(!env.no_color());
}
#[test]
fn with_paths_replaces_the_layout() {
let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
let env = Env::for_test("/tmp/x").with_paths(split.clone());
assert_eq!(env.paths(), Some(&split));
assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
}
#[test]
fn empty_has_no_paths() {
let env = Env::empty();
assert!(env.paths().is_none());
}
#[test]
fn sandboxed_gives_isolated_existing_dirs() {
let a = Env::sandboxed();
let b = Env::sandboxed();
let pa = a.paths().unwrap().home().to_path_buf();
let pb = b.paths().unwrap().home().to_path_buf();
assert_ne!(pa, pb, "each sandbox is a distinct directory");
assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
let prefs = a.paths().unwrap().preferences();
crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
}
#[test]
fn with_var_sets_typed_accessors() {
let env = Env::for_test("/tmp/x")
.with_var("VAULT_ADDR", "https://vault.example:8200")
.with_var("COLORTERM", "truecolor")
.with_var("NO_COLOR", "1")
.with_var("TMUX", "/tmp/tmux-1000/default,1,0");
assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
assert_eq!(env.colorterm(), Some("truecolor"));
assert!(env.no_color());
assert!(env.in_tmux());
}
#[test]
fn aws_credentials_require_both_keys() {
let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
assert_eq!(only_id.aws_credentials(), None);
let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
}
#[test]
fn purple_token_treats_a_blank_variable_as_absent() {
assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
for blank in ["", " "] {
assert_eq!(
Env::for_test("/tmp/x")
.with_var("PURPLE_TOKEN", blank)
.purple_token(),
None,
"a blank PURPLE_TOKEN must read as absent"
);
}
assert_eq!(
Env::for_test("/tmp/x")
.with_var("PURPLE_TOKEN", "tok")
.purple_token(),
Some("tok")
);
}
#[test]
fn aws_session_token_reads_env_var() {
let env = Env::for_test("/tmp/x");
assert_eq!(env.aws_session_token(), None);
let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
}
#[test]
fn aws_session_token_is_independent_of_key_pair() {
let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
assert_eq!(env.aws_credentials(), None);
assert_eq!(env.aws_session_token(), Some("TOKEN"));
}
#[test]
fn active_proxy_vars_filters_empty_and_orders() {
let env = Env::for_test("/tmp/x")
.with_var("HTTPS_PROXY", "http://proxy:3128")
.with_var("HTTP_PROXY", "")
.with_var("NO_PROXY", "localhost");
assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
}
#[test]
fn debug_redacts_secret_values() {
let env = Env::for_test("/tmp/x")
.with_var("PURPLE_TOKEN", "super-secret")
.with_var("VAULT_ADDR", "https://vault.example:8200");
let rendered = format!("{env:?}");
assert!(!rendered.contains("super-secret"));
assert!(!rendered.contains("vault.example"));
assert!(rendered.contains("PURPLE_TOKEN"));
assert!(rendered.contains("VAULT_ADDR"));
}
#[test]
fn from_process_captures_home_and_vars() {
let env = Env::from_process();
let _ = env.paths();
let _ = env.var("PATH");
}
#[test]
fn the_aws_files_default_to_the_dot_aws_directory() {
let env = Env::for_test("/home/u");
assert_eq!(
env.aws_credentials_file(),
Some(PathBuf::from("/home/u/.aws/credentials"))
);
assert_eq!(
env.aws_config_file(),
Some(PathBuf::from("/home/u/.aws/config"))
);
}
#[test]
fn an_absolute_override_relocates_the_aws_file() {
let env = Env::for_test("/home/u")
.with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
.with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
assert_eq!(
env.aws_credentials_file(),
Some(PathBuf::from("/etc/aws/creds"))
);
assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
}
#[test]
fn a_home_relative_override_expands_against_the_home_directory() {
let env = Env::for_test("/home/u")
.with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
.with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
assert_eq!(
env.aws_credentials_file(),
Some(PathBuf::from("/home/u/work/creds"))
);
assert_eq!(
env.aws_config_file(),
Some(PathBuf::from("/home/u/work/conf"))
);
}
#[test]
fn a_bare_tilde_override_is_the_home_directory() {
let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
}
#[test]
fn an_exported_but_blank_override_means_no_file() {
let env = Env::for_test("/home/u")
.with_var("AWS_SHARED_CREDENTIALS_FILE", "")
.with_var("AWS_CONFIG_FILE", " ");
assert_eq!(env.aws_credentials_file(), None);
assert_eq!(env.aws_config_file(), None);
}
#[test]
fn without_a_home_directory_only_an_override_names_an_aws_file() {
assert_eq!(Env::empty().aws_credentials_file(), None);
assert_eq!(
Env::empty()
.with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
.aws_config_file(),
Some(PathBuf::from("/etc/aws/conf"))
);
}
}