use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use crate::EjsonError;
use crate::typed::DecryptedContent;
use thiserror::Error;
use zeroize::Zeroize;
pub struct SecretEnvMap {
inner: BTreeMap<String, String>,
}
impl SecretEnvMap {
pub fn new() -> Self {
Self {
inner: BTreeMap::new(),
}
}
pub fn insert(&mut self, key: String, value: String) {
self.inner.insert(key, value);
}
pub fn get(&self, key: &str) -> Option<&String> {
self.inner.get(key)
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn iter(&self) -> std::collections::btree_map::Iter<'_, String, String> {
self.inner.iter()
}
}
impl Default for SecretEnvMap {
fn default() -> Self {
Self::new()
}
}
impl Zeroize for SecretEnvMap {
fn zeroize(&mut self) {
for (mut key, mut value) in std::mem::take(&mut self.inner) {
key.zeroize();
value.zeroize();
}
}
}
impl Drop for SecretEnvMap {
fn drop(&mut self) {
self.zeroize();
}
}
impl std::fmt::Debug for SecretEnvMap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretEnvMap")
.field("len", &self.inner.len())
.field("keys", &self.inner.keys().collect::<Vec<_>>())
.field("values", &"[REDACTED]")
.finish()
}
}
impl<'a> IntoIterator for &'a SecretEnvMap {
type Item = (&'a String, &'a String);
type IntoIter = std::collections::btree_map::Iter<'a, String, String>;
fn into_iter(self) -> Self::IntoIter {
self.inner.iter()
}
}
#[inline]
fn is_valid_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Error, Debug)]
pub enum EnvError {
#[error("environment is not set in ejson/eyaml/etoml")]
NoEnv,
#[error("environment is not a map[string]interface{{}}")]
EnvNotMap,
#[error("invalid identifier as key in environment: {0:?}")]
InvalidIdentifier(String),
#[error("could not load ejson/eyaml/etoml file: {0}")]
LoadError(String),
#[error("could not load environment from file: {0}")]
EnvLoadError(String),
#[error("ejson error: {0}")]
Ejson(#[from] EjsonError),
}
pub type ExportFunction = fn(&mut dyn Write, &SecretEnvMap) -> Result<(), EnvError>;
pub fn is_env_error(err: &EnvError) -> bool {
matches!(err, EnvError::NoEnv | EnvError::EnvNotMap)
}
pub fn extract_env(content: &DecryptedContent) -> Result<SecretEnvMap, EnvError> {
let raw_env = content.get("environment").ok_or(EnvError::NoEnv)?;
let env_map = raw_env.as_string_map().ok_or(EnvError::EnvNotMap)?;
let mut env_secrets = SecretEnvMap::new();
for (key, raw_value) in env_map {
if !is_valid_identifier(key) {
return Err(EnvError::InvalidIdentifier(key.to_string()));
}
if let Some(value) = raw_value.as_str() {
env_secrets.insert(key.to_string(), value.to_string());
}
}
Ok(env_secrets)
}
pub fn read_and_extract_env<P: AsRef<Path>>(
file_path: P,
keydir: &str,
private_key: &str,
trim_underscore_prefix: bool,
) -> Result<SecretEnvMap, EnvError> {
let content =
crate::decrypt_file_typed(file_path, keydir, private_key, trim_underscore_prefix)?;
extract_env(&content)
}
pub fn read_and_export_env<P: AsRef<Path>, W: Write>(
file_path: P,
keydir: &str,
private_key: &str,
trim_underscore_prefix: bool,
export_func: ExportFunction,
output: &mut W,
) -> Result<(), EnvError> {
let env_values =
match read_and_extract_env(&file_path, keydir, private_key, trim_underscore_prefix) {
Ok(values) => values,
Err(e) if is_env_error(&e) => SecretEnvMap::new(),
Err(e) => return Err(EnvError::EnvLoadError(e.to_string())),
};
export_func(output, &env_values)
}
fn filtered_value(v: &str) -> (String, bool) {
let mut had_control_chars = false;
let filtered: String = v
.chars()
.filter(|&c| {
if c.is_control() && c != '\n' {
had_control_chars = true;
false
} else {
true
}
})
.collect();
(filtered, had_control_chars)
}
fn shell_quote(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 2);
result.push('\'');
for c in s.chars() {
if c == '\'' {
result.push_str("'\"'\"'");
} else {
result.push(c);
}
}
result.push('\'');
result
}
fn export(w: &mut dyn Write, prefix: &str, values: &SecretEnvMap) -> Result<(), EnvError> {
for (k, v) in values {
if !is_valid_identifier(k) {
return Err(EnvError::InvalidIdentifier(k.to_string()));
}
let (filtered, had_control_chars) = filtered_value(v);
if had_control_chars {
eprintln!("ejson env trimmed control characters from value");
}
let quoted = shell_quote(&filtered);
let _ = writeln!(w, "{}{}={}", prefix, k, quoted);
}
Ok(())
}
pub fn export_env(w: &mut dyn Write, values: &SecretEnvMap) -> Result<(), EnvError> {
export(w, "export ", values)
}
pub fn export_quiet(w: &mut dyn Write, values: &SecretEnvMap) -> Result<(), EnvError> {
export(w, "", values)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DecryptedContent;
#[test]
fn test_is_valid_identifier() {
assert!(is_valid_identifier("ALL_CAPS123"));
assert!(is_valid_identifier("lowercase"));
assert!(is_valid_identifier("a"));
assert!(is_valid_identifier("_leading_underscore"));
assert!(!is_valid_identifier("1_leading_digit"));
assert!(!is_valid_identifier("contains whitespace"));
assert!(!is_valid_identifier("contains-dash"));
assert!(!is_valid_identifier("contains_special_character;"));
assert!(!is_valid_identifier("")); assert!(!is_valid_identifier("key\nnewline"));
}
#[test]
fn test_filtered_value() {
let (filtered, had_control) = filtered_value("normal value");
assert_eq!(filtered, "normal value");
assert!(!had_control);
let (filtered, had_control) = filtered_value("value\nwith\nnewlines");
assert_eq!(filtered, "value\nwith\nnewlines");
assert!(!had_control);
let (filtered, had_control) = filtered_value("\x08value with control");
assert_eq!(filtered, "value with control");
assert!(had_control);
}
#[test]
fn test_extract_env_json_no_env() {
let json_value = serde_json::json!({
"_public_key": "abc123"
});
let content = DecryptedContent::Json(json_value);
let result = extract_env(&content);
assert!(matches!(result, Err(EnvError::NoEnv)));
}
#[test]
fn test_extract_env_json_not_map() {
let json_value = serde_json::json!({
"_public_key": "abc123",
"environment": "not a map"
});
let content = DecryptedContent::Json(json_value);
let result = extract_env(&content);
assert!(matches!(result, Err(EnvError::EnvNotMap)));
}
#[test]
fn test_extract_env_json_invalid_key() {
let json_value = serde_json::json!({
"_public_key": "abc123",
"environment": {
"invalid key": "value"
}
});
let content = DecryptedContent::Json(json_value);
let result = extract_env(&content);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_extract_env_json_valid() {
let json_value = serde_json::json!({
"_public_key": "abc123",
"environment": {
"test_key": "test_value",
"_underscore_key": "underscore_value"
}
});
let content = DecryptedContent::Json(json_value);
let result = extract_env(&content).unwrap();
assert_eq!(result.get("test_key"), Some(&"test_value".to_string()));
assert_eq!(
result.get("_underscore_key"),
Some(&"underscore_value".to_string())
);
}
#[test]
fn test_export_env() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "value".to_string());
export_env(&mut output, &values).unwrap();
assert_eq!(String::from_utf8(output).unwrap(), "export key='value'\n");
}
#[test]
fn test_export_quiet() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "value".to_string());
export_quiet(&mut output, &values).unwrap();
assert_eq!(String::from_utf8(output).unwrap(), "key='value'\n");
}
#[test]
fn test_export_escaping() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert(
"test".to_string(),
"test value'; echo dangerous; echo 'done".to_string(),
);
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
let expected = "export test='test value'\"'\"'; echo dangerous; echo '\"'\"'done'\n";
assert_eq!(result, expected);
}
#[test]
fn test_command_injection_in_key() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key; touch pwned.txt".to_string(), "value".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_empty_key_returns_error() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("".to_string(), "value".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_dash_in_key_returns_error() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key-with-dash".to_string(), "value".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_newline_in_value() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "value\nnewline".to_string());
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("newline"));
}
#[test]
fn test_is_env_error() {
assert!(is_env_error(&EnvError::NoEnv));
assert!(is_env_error(&EnvError::EnvNotMap));
assert!(!is_env_error(&EnvError::InvalidIdentifier(
"test".to_string()
)));
}
#[test]
fn test_secret_env_map_debug_redacts_values() {
let mut secrets = SecretEnvMap::new();
secrets.insert("API_KEY".to_string(), "super_secret_key_123".to_string());
secrets.insert("DB_PASSWORD".to_string(), "password123".to_string());
let debug_output = format!("{:?}", secrets);
assert!(debug_output.contains("API_KEY"));
assert!(debug_output.contains("DB_PASSWORD"));
assert!(debug_output.contains("[REDACTED]"));
assert!(!debug_output.contains("super_secret_key_123"));
assert!(!debug_output.contains("password123"));
}
#[test]
fn test_secret_env_map_zeroize() {
let mut secrets = SecretEnvMap::new();
secrets.insert("KEY".to_string(), "secret_value".to_string());
assert_eq!(secrets.len(), 1);
assert!(secrets.get("KEY").is_some());
secrets.zeroize();
assert!(secrets.is_empty());
assert_eq!(secrets.len(), 0);
}
#[test]
fn test_secret_env_map_into_iterator() {
let mut secrets = SecretEnvMap::new();
secrets.insert("ZEBRA".to_string(), "value_z".to_string());
secrets.insert("ALPHA".to_string(), "value_a".to_string());
secrets.insert("MIKE".to_string(), "value_m".to_string());
let collected: Vec<_> = (&secrets).into_iter().collect();
assert_eq!(collected.len(), 3);
let keys: Vec<_> = collected.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["ALPHA", "MIKE", "ZEBRA"]);
let pairs: Vec<_> = collected
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
pairs,
vec![
("ALPHA", "value_a"),
("MIKE", "value_m"),
("ZEBRA", "value_z")
]
);
let mut count = 0;
for (k, v) in &secrets {
assert!(!k.is_empty());
assert!(!v.is_empty());
count += 1;
}
assert_eq!(count, 3);
}
#[test]
fn test_shell_quote_backticks() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "`whoami`".to_string());
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "export key='`whoami`'\n");
}
#[test]
fn test_shell_quote_dollar_expansion() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "$HOME".to_string());
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "export key='$HOME'\n");
}
#[test]
fn test_shell_quote_subshell() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "$(cat /etc/passwd)".to_string());
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "export key='$(cat /etc/passwd)'\n");
}
#[test]
fn test_key_with_equals_returns_error() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key=value".to_string(), "test".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_key_with_backtick_returns_error() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key`whoami`".to_string(), "test".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_key_with_dollar_returns_error() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("$KEY".to_string(), "test".to_string());
let result = export_env(&mut output, &values);
assert!(matches!(result, Err(EnvError::InvalidIdentifier(_))));
}
#[test]
fn test_value_with_null_byte() {
let mut output = Vec::new();
let mut values = SecretEnvMap::new();
values.insert("key".to_string(), "before\x00after".to_string());
export_env(&mut output, &values).unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "export key='beforeafter'\n");
}
}