use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Clone, PartialEq, Eq)]
pub struct Redacted<T>(T);
impl<T> Redacted<T> {
pub fn new(v: T) -> Self {
Self(v)
}
pub fn expose(&self) -> &T {
&self.0
}
}
impl<T> fmt::Debug for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Redacted(***)")
}
}
impl<T> fmt::Display for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***REDACTED***")
}
}
#[derive(Debug)]
pub enum ExpandError {
Required { var: String, message: String },
UnknownSecret { key: String },
Syntax(String),
}
impl fmt::Display for ExpandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExpandError::Required { var, message } => {
write!(f, "required variable `{var}` is unset: {message}")
}
ExpandError::UnknownSecret { key } => {
write!(f, "unknown secret `{key}` referenced via ${{secret:..}}")
}
ExpandError::Syntax(msg) => write!(f, "interpolation syntax error: {msg}"),
}
}
}
impl std::error::Error for ExpandError {}
#[derive(Debug)]
pub enum SecretsLoadError {
InsecurePermissions { path: PathBuf, mode: u32 },
Io { path: PathBuf, error: String },
Parse { path: PathBuf, error: String },
}
impl fmt::Display for SecretsLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecretsLoadError::InsecurePermissions { path, mode } => write!(
f,
"refusing to load secrets file {} with insecure mode {:o} (require 0600)",
path.display(),
mode & 0o777
),
SecretsLoadError::Io { path, error } => {
write!(f, "cannot read secrets file {}: {error}", path.display())
}
SecretsLoadError::Parse { path, error } => {
write!(f, "parse error in secrets file {}: {error}", path.display())
}
}
}
}
impl std::error::Error for SecretsLoadError {}
#[derive(Default, Debug)]
pub struct EnvContext {
per_pane: HashMap<String, String>,
dotenv: HashMap<String, String>,
secrets: HashMap<String, Redacted<String>>,
process: HashMap<String, String>,
}
impl EnvContext {
pub fn build(
dotenv: HashMap<String, String>,
secrets: HashMap<String, Redacted<String>>,
) -> Self {
let process = std::env::vars().collect();
Self {
per_pane: HashMap::new(),
dotenv,
secrets,
process,
}
}
#[allow(dead_code)]
pub fn build_with_process(
dotenv: HashMap<String, String>,
secrets: HashMap<String, Redacted<String>>,
process: HashMap<String, String>,
) -> Self {
Self {
per_pane: HashMap::new(),
dotenv,
secrets,
process,
}
}
pub fn with_pane(&self, per_pane: HashMap<String, String>) -> Self {
Self {
per_pane,
dotenv: self.dotenv.clone(),
secrets: self.secrets.clone(),
process: self.process.clone(),
}
}
fn lookup_plain(&self, name: &str) -> Option<&str> {
if let Some(v) = self.per_pane.get(name) {
return Some(v.as_str());
}
if let Some(v) = self.dotenv.get(name) {
return Some(v.as_str());
}
self.process.get(name).map(|s| s.as_str())
}
fn lookup_secret(&self, key: &str) -> Option<&str> {
self.secrets.get(key).map(|r| r.expose().as_str())
}
}
pub fn parse_dotenv(contents: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((k, v)) = line.split_once('=') else {
continue;
};
let key = k.trim().to_string();
if key.is_empty() {
continue;
}
let v = v.trim();
let value = if (v.starts_with('"') && v.ends_with('"') && v.len() >= 2)
|| (v.starts_with('\'') && v.ends_with('\'') && v.len() >= 2)
{
v[1..v.len() - 1].to_string()
} else {
v.to_string()
};
out.insert(key, value);
}
out
}
pub fn load_dotenv(project_dir: &Path) -> Result<HashMap<String, String>, String> {
let path = project_dir.join(".env.local");
if !path.exists() {
return Ok(HashMap::new());
}
let contents = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
Ok(parse_dotenv(&contents))
}
pub fn default_secrets_path() -> PathBuf {
let dir = std::env::var("EZPN_TEST_SECRETS_DIR")
.or_else(|_| std::env::var("XDG_RUNTIME_DIR"))
.unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(dir).join("ezpn").join("secrets.toml")
}
pub fn load_secrets(path: &Path) -> Result<HashMap<String, Redacted<String>>, SecretsLoadError> {
if !path.exists() {
return Ok(HashMap::new());
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).map_err(|e| SecretsLoadError::Io {
path: path.to_path_buf(),
error: e.to_string(),
})?;
let mode = meta.mode() & 0o777;
if mode != 0o600 {
return Err(SecretsLoadError::InsecurePermissions {
path: path.to_path_buf(),
mode,
});
}
}
let contents = std::fs::read_to_string(path).map_err(|e| SecretsLoadError::Io {
path: path.to_path_buf(),
error: e.to_string(),
})?;
let table: toml::Table = toml::from_str(&contents).map_err(|e| SecretsLoadError::Parse {
path: path.to_path_buf(),
error: e.to_string(),
})?;
let mut out = HashMap::new();
for (k, v) in table {
if let toml::Value::String(s) = v {
out.insert(k, Redacted::new(s));
}
}
Ok(out)
}
pub fn expand(template: &str, ctx: &EnvContext) -> Result<String, ExpandError> {
let bytes = template.as_bytes();
let mut out = String::with_capacity(template.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
out.push('$');
i += 2;
continue;
}
if b == b'$' {
if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
let close = find_matching_brace(bytes, i + 1)?;
let inner = &template[i + 2..close];
let resolved = expand_braced(inner, ctx)?;
out.push_str(&resolved);
i = close + 1;
continue;
}
let start = i + 1;
let end = scan_identifier_end(bytes, start);
if end > start {
let name = &template[start..end];
if let Some(v) = ctx.lookup_plain(name) {
out.push_str(v);
}
i = end;
continue;
}
out.push('$');
i += 1;
continue;
}
out.push(b as char);
i += 1;
}
Ok(out)
}
fn find_matching_brace(bytes: &[u8], open_idx: usize) -> Result<usize, ExpandError> {
debug_assert_eq!(bytes[open_idx], b'{');
let mut j = open_idx + 1;
while j < bytes.len() {
if bytes[j] == b'}' {
return Ok(j);
}
j += 1;
}
Err(ExpandError::Syntax(format!(
"unterminated `${{` starting at byte {open_idx}"
)))
}
fn scan_identifier_end(bytes: &[u8], start: usize) -> usize {
if start >= bytes.len() {
return start;
}
let first = bytes[start];
if !(first.is_ascii_alphabetic() || first == b'_') {
return start;
}
let mut j = start + 1;
while j < bytes.len() {
let c = bytes[j];
if c.is_ascii_alphanumeric() || c == b'_' {
j += 1;
} else {
break;
}
}
j
}
fn expand_braced(inner: &str, ctx: &EnvContext) -> Result<String, ExpandError> {
if let Some(key) = inner.strip_prefix("secret:") {
let key = key.trim();
if key.is_empty() {
return Err(ExpandError::Syntax(
"empty key in `${secret:..}`".to_string(),
));
}
return ctx
.lookup_secret(key)
.map(|s| s.to_string())
.ok_or_else(|| ExpandError::UnknownSecret {
key: key.to_string(),
});
}
if let Some(idx) = inner.find(":?") {
let name = &inner[..idx];
let msg = &inner[idx + 2..];
validate_name(name)?;
return match ctx.lookup_plain(name) {
Some(v) if !v.is_empty() => Ok(v.to_string()),
_ => Err(ExpandError::Required {
var: name.to_string(),
message: msg.to_string(),
}),
};
}
if let Some(idx) = inner.find(":-") {
let name = &inner[..idx];
let default = &inner[idx + 2..];
validate_name(name)?;
return match ctx.lookup_plain(name) {
Some(v) if !v.is_empty() => Ok(v.to_string()),
_ => Ok(default.to_string()),
};
}
validate_name(inner)?;
Ok(ctx.lookup_plain(inner).unwrap_or("").to_string())
}
fn validate_name(name: &str) -> Result<(), ExpandError> {
if name.is_empty() {
return Err(ExpandError::Syntax("empty variable name in `${}`".into()));
}
let bytes = name.as_bytes();
let first = bytes[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return Err(ExpandError::Syntax(format!(
"invalid variable name `{name}`"
)));
}
for &c in &bytes[1..] {
if !(c.is_ascii_alphanumeric() || c == b'_') {
return Err(ExpandError::Syntax(format!(
"invalid variable name `{name}`"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with(process: &[(&str, &str)]) -> EnvContext {
let process: HashMap<String, String> = process
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
EnvContext::build_with_process(HashMap::new(), HashMap::new(), process)
}
#[test]
fn bare_dollar_var_set() {
let ctx = ctx_with(&[("EDITOR", "nvim")]);
assert_eq!(expand("$EDITOR .", &ctx).unwrap(), "nvim .");
}
#[test]
fn bare_dollar_var_unset_expands_empty() {
let ctx = ctx_with(&[]);
assert_eq!(expand("[$MISSING]", &ctx).unwrap(), "[]");
}
#[test]
fn braced_var_set() {
let ctx = ctx_with(&[("PORT", "9000")]);
assert_eq!(expand("port=${PORT}", &ctx).unwrap(), "port=9000");
}
#[test]
fn braced_var_unset_expands_empty() {
let ctx = ctx_with(&[]);
assert_eq!(expand("[${MISSING}]", &ctx).unwrap(), "[]");
}
#[test]
fn default_used_when_unset() {
let ctx = ctx_with(&[]);
assert_eq!(expand("${PORT:-3000}", &ctx).unwrap(), "3000");
}
#[test]
fn default_used_when_empty() {
let ctx = ctx_with(&[("PORT", "")]);
assert_eq!(expand("${PORT:-3000}", &ctx).unwrap(), "3000");
}
#[test]
fn default_skipped_when_set() {
let ctx = ctx_with(&[("PORT", "8080")]);
assert_eq!(expand("${PORT:-3000}", &ctx).unwrap(), "8080");
}
#[test]
fn required_errors_when_unset() {
let ctx = ctx_with(&[]);
let err = expand("${API_KEY:?must be set}", &ctx).unwrap_err();
match err {
ExpandError::Required { var, message } => {
assert_eq!(var, "API_KEY");
assert_eq!(message, "must be set");
}
other => panic!("expected Required, got {other:?}"),
}
}
#[test]
fn required_errors_when_empty() {
let ctx = ctx_with(&[("API_KEY", "")]);
let err = expand("${API_KEY:?must be set}", &ctx).unwrap_err();
assert!(matches!(err, ExpandError::Required { .. }));
}
#[test]
fn required_passes_when_set() {
let ctx = ctx_with(&[("API_KEY", "abc")]);
assert_eq!(expand("${API_KEY:?must be set}", &ctx).unwrap(), "abc");
}
#[test]
fn dollar_dollar_is_literal() {
let ctx = ctx_with(&[]);
assert_eq!(expand("price: $$5", &ctx).unwrap(), "price: $5");
}
#[test]
fn dotenv_overrides_process_env() {
let process: HashMap<String, String> = [("PORT".to_string(), "9999".to_string())]
.into_iter()
.collect();
let dotenv: HashMap<String, String> = [("PORT".to_string(), "3000".to_string())]
.into_iter()
.collect();
let ctx = EnvContext::build_with_process(dotenv, HashMap::new(), process);
assert_eq!(expand("${PORT}", &ctx).unwrap(), "3000");
}
#[test]
fn per_pane_overrides_dotenv_and_process() {
let process: HashMap<String, String> = [("HOST".to_string(), "p".to_string())]
.into_iter()
.collect();
let dotenv: HashMap<String, String> = [("HOST".to_string(), "d".to_string())]
.into_iter()
.collect();
let base = EnvContext::build_with_process(dotenv, HashMap::new(), process);
let per: HashMap<String, String> = [("HOST".to_string(), "pane".to_string())]
.into_iter()
.collect();
let ctx = base.with_pane(per);
assert_eq!(expand("${HOST}", &ctx).unwrap(), "pane");
}
#[test]
fn secret_resolves_from_map() {
let secrets: HashMap<String, Redacted<String>> = [(
"DB_PASSWORD".to_string(),
Redacted::new("hunter2".to_string()),
)]
.into_iter()
.collect();
let ctx = EnvContext::build_with_process(HashMap::new(), secrets, HashMap::new());
assert_eq!(expand("${secret:DB_PASSWORD}", &ctx).unwrap(), "hunter2");
}
#[test]
fn secret_unknown_errors() {
let ctx = ctx_with(&[]);
let err = expand("${secret:NOPE}", &ctx).unwrap_err();
assert!(matches!(err, ExpandError::UnknownSecret { ref key } if key == "NOPE"));
}
#[test]
fn secret_not_resolved_via_plain_var() {
let secrets: HashMap<String, Redacted<String>> =
[("API".to_string(), Redacted::new("abc".to_string()))]
.into_iter()
.collect();
let ctx = EnvContext::build_with_process(HashMap::new(), secrets, HashMap::new());
assert_eq!(expand("${API}", &ctx).unwrap(), "");
}
#[cfg(unix)]
#[test]
fn load_secrets_with_mode_0600_succeeds() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
std::fs::write(&path, "DB_PASSWORD = \"hunter2\"\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let secrets = load_secrets(&path).expect("load");
assert_eq!(secrets.get("DB_PASSWORD").unwrap().expose(), "hunter2");
let ctx = EnvContext::build_with_process(HashMap::new(), secrets, HashMap::new());
assert_eq!(expand("${secret:DB_PASSWORD}", &ctx).unwrap(), "hunter2");
}
#[cfg(unix)]
#[test]
fn load_secrets_with_mode_0644_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
std::fs::write(&path, "DB_PASSWORD = \"hunter2\"\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = load_secrets(&path).expect_err("must refuse wider perms");
match err {
SecretsLoadError::InsecurePermissions { mode, .. } => {
assert_eq!(mode & 0o777, 0o644);
}
other => panic!("expected InsecurePermissions, got {other}"),
}
}
#[cfg(unix)]
#[test]
fn load_secrets_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let secrets = load_secrets(&path).expect("missing file is not an error");
assert!(secrets.is_empty());
}
#[test]
fn redacted_debug_does_not_leak() {
let r = Redacted::new("hunter2".to_string());
let dbg = format!("{:?}", r);
let disp = format!("{}", r);
assert!(!dbg.contains("hunter2"), "Debug leaked: {dbg}");
assert!(!disp.contains("hunter2"), "Display leaked: {disp}");
}
#[test]
fn env_context_debug_does_not_leak_secrets() {
let secrets: HashMap<String, Redacted<String>> = [(
"DB_PASSWORD".to_string(),
Redacted::new("supersecret-token-xyz".to_string()),
)]
.into_iter()
.collect();
let ctx = EnvContext::build_with_process(HashMap::new(), secrets, HashMap::new());
let dbg = format!("{:?}", ctx);
assert!(
!dbg.contains("supersecret-token-xyz"),
"EnvContext Debug leaked secret: {dbg}"
);
}
#[test]
fn expand_error_does_not_embed_value() {
let secrets: HashMap<String, Redacted<String>> = [(
"PRESENT".to_string(),
Redacted::new("real-secret".to_string()),
)]
.into_iter()
.collect();
let ctx = EnvContext::build_with_process(HashMap::new(), secrets, HashMap::new());
let err = expand("${secret:MISSING}", &ctx).unwrap_err();
let s = format!("{err}");
assert!(s.contains("MISSING"));
assert!(!s.contains("real-secret"));
}
#[test]
fn parse_dotenv_basic() {
let m = parse_dotenv("FOO=bar\n# comment\nBAZ=\"quoted\"\nexport QUX='single'\n\n");
assert_eq!(m.get("FOO"), Some(&"bar".to_string()));
assert_eq!(m.get("BAZ"), Some(&"quoted".to_string()));
assert_eq!(m.get("QUX"), Some(&"single".to_string()));
}
#[test]
fn parse_dotenv_skips_malformed_lines() {
let m = parse_dotenv("not an assignment\n=novalue\nGOOD=ok\n");
assert_eq!(m.len(), 1);
assert_eq!(m.get("GOOD"), Some(&"ok".to_string()));
}
#[test]
fn unterminated_brace_errors() {
let ctx = ctx_with(&[]);
let err = expand("${UNTERMINATED", &ctx).unwrap_err();
assert!(matches!(err, ExpandError::Syntax(_)));
}
#[test]
fn invalid_name_errors() {
let ctx = ctx_with(&[]);
let err = expand("${1BAD}", &ctx).unwrap_err();
assert!(matches!(err, ExpandError::Syntax(_)));
}
#[test]
fn empty_secret_key_errors() {
let ctx = ctx_with(&[]);
let err = expand("${secret:}", &ctx).unwrap_err();
assert!(matches!(err, ExpandError::Syntax(_)));
}
#[test]
fn mixed_template() {
let ctx = ctx_with(&[("USER", "alice"), ("HOME", "/home/alice")]);
assert_eq!(
expand("${USER}@host:$HOME/${SUBDIR:-projects}", &ctx).unwrap(),
"alice@host:/home/alice/projects"
);
}
#[test]
fn lone_dollar_at_end_is_literal() {
let ctx = ctx_with(&[]);
assert_eq!(expand("price: $", &ctx).unwrap(), "price: $");
}
#[test]
fn dollar_followed_by_non_ident_is_literal() {
let ctx = ctx_with(&[]);
assert_eq!(expand("a $1 b", &ctx).unwrap(), "a $1 b");
}
}