use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use crate::{
errors::{SafeError, SafeResult},
profile,
};
pub fn format_env(secrets: &HashMap<String, String>) -> String {
sorted_pairs(secrets)
.map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_dotenv(secrets: &HashMap<String, String>) -> String {
sorted_pairs(secrets)
.map(|(k, v)| {
let escaped = v
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
.replace('\n', "\\n")
.replace('\r', "\\r");
format!("export {k}=\"{escaped}\"")
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_powershell(secrets: &HashMap<String, String>) -> String {
sorted_pairs(secrets)
.map(|(k, v)| {
let escaped = v
.replace('`', "``")
.replace('"', "`\"")
.replace('$', "`$")
.replace('\n', "`n")
.replace('\r', "`r");
format!("$env:{k} = \"{escaped}\"")
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_json(secrets: &HashMap<String, String>) -> SafeResult<String> {
serde_json::to_string_pretty(secrets).map_err(SafeError::Serialization)
}
pub fn format_yaml(secrets: &HashMap<String, String>) -> SafeResult<String> {
let lines: Vec<String> = {
let mut pairs: Vec<(&str, &str)> = secrets
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
pairs.sort_by_key(|(k, _)| *k);
pairs
.into_iter()
.map(|(k, v)| {
let escaped = v
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r");
format!("{k}: \"{escaped}\"")
})
.collect()
};
Ok(lines.join("\n"))
}
pub fn format_docker_env(secrets: &HashMap<String, String>) -> String {
sorted_pairs(secrets)
.map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_github_actions(secrets: &HashMap<String, String>) -> String {
sorted_pairs(secrets)
.flat_map(|(k, v)| {
let safe_v = v.replace('\n', "%0A").replace('\r', "%0D");
[format!("::add-mask::{safe_v}"), format!("{k}={safe_v}")]
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_toml(pairs: &[(impl AsRef<str>, impl AsRef<str>)]) -> String {
let mut sorted: Vec<(&str, &str)> = pairs
.iter()
.map(|(k, v)| (k.as_ref(), v.as_ref()))
.collect();
sorted.sort_by_key(|(k, _)| *k);
sorted
.into_iter()
.map(|(k, v)| {
let key = if is_bare_toml_key(k) {
k.to_owned()
} else {
format!("\"{}\"", escape_toml_string(k))
};
let value = escape_toml_string(v);
format!("{key} = \"{value}\"")
})
.collect::<Vec<_>>()
.join("\n")
}
fn is_bare_toml_key(k: &str) -> bool {
!k.is_empty()
&& k.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn escape_toml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn sorted_pairs(m: &HashMap<String, String>) -> impl Iterator<Item = (&str, &str)> {
let mut pairs: Vec<(&str, &str)> = m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
pairs.sort_by_key(|(k, _)| *k);
pairs.into_iter()
}
pub fn parse_dotenv(path: &Path) -> SafeResult<HashMap<String, String>> {
let raw_content = std::fs::read_to_string(path).map_err(|e| SafeError::ImportParse {
file: path.display().to_string(),
reason: e.to_string(),
})?;
let content = raw_content.strip_prefix('\u{FEFF}').unwrap_or(&raw_content);
let mut raw_pairs: Vec<(String, String)> = Vec::new();
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some(eq) = line.find('=') else { continue };
let raw_key = line[..eq].trim();
let raw_key = raw_key
.strip_prefix("export")
.map(str::trim)
.unwrap_or(raw_key);
let raw_key = raw_key.strip_prefix('$').unwrap_or(raw_key);
let key = raw_key.trim().to_string();
if key.is_empty() || key.contains(|c: char| c.is_whitespace()) {
continue;
}
let raw_val = strip_quotes(line[eq + 1..].trim()).to_string();
raw_pairs.push((key, raw_val));
}
let mut literals: HashMap<String, String> = HashMap::new();
for (k, v) in &raw_pairs {
if !v.starts_with('$') {
literals.insert(k.clone(), v.clone());
}
}
let mut map = HashMap::new();
for (key, val) in raw_pairs {
let resolved = resolve_env_ref(&val, &literals);
map.insert(key, resolved);
}
Ok(map)
}
fn strip_quotes(s: &str) -> &str {
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
{
&s[1..s.len() - 1]
} else {
s
}
}
fn resolve_env_ref(val: &str, file_locals: &HashMap<String, String>) -> String {
if let Some(name) = val.strip_prefix('$') {
if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
if let Ok(resolved) = std::env::var(name) {
return resolved;
}
if let Some(resolved) = file_locals.get(name) {
return resolved.clone();
}
}
}
val.to_string()
}
const SENSITIVE_VARS: &[&str] = &[
"TSAFE_PASSWORD",
"TSAFE_NEW_MASTER_PASSWORD",
"AZURE_CLIENT_SECRET",
"VAULT_TOKEN",
"TSAFE_AKV_URL",
"TSAFE_HCP_URL",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"ADO_PAT",
"ADO_PAT2",
"GITHUB_TOKEN",
"GH_TOKEN",
"GITLAB_TOKEN",
"NPM_TOKEN",
"PYPI_TOKEN",
"NUGET_API_KEY",
];
const DANGEROUS_INJECTED_ENV_NAMES: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"NODE_OPTIONS",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
];
pub fn is_dangerous_injected_env_name(name: &str) -> bool {
DANGEROUS_INJECTED_ENV_NAMES
.iter()
.any(|d| d.eq_ignore_ascii_case(name))
}
pub fn sensitive_parent_env_vars() -> Vec<String> {
let mut out = Vec::new();
for name in SENSITIVE_VARS
.iter()
.map(|name| (*name).to_string())
.chain(profile::get_exec_extra_sensitive_parent_vars())
{
if !out
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(&name))
{
out.push(name);
}
}
out
}
pub const MINIMAL_ENV_VARS: &[&str] = &[
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"TERM",
"TERM_PROGRAM",
"COLORTERM",
"NO_COLOR",
"FORCE_COLOR",
"PWD",
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
];
fn apply_exec_environment(
cmd: &mut Command,
secrets: &HashMap<String, String>,
extra_strip_names: &[String],
) {
let mut strip_names = sensitive_parent_env_vars();
for name in extra_strip_names {
if !strip_names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(name))
{
strip_names.push(name.clone());
}
}
for var in strip_names {
cmd.env_remove(var);
}
for (k, v) in secrets {
cmd.env(k, v);
}
}
pub fn command_with_secrets(
secrets: &HashMap<String, String>,
cmd_parts: &[String],
) -> SafeResult<Command> {
command_with_secrets_and_extra_strips(secrets, &[], cmd_parts)
}
pub fn command_with_secrets_and_extra_strips(
secrets: &HashMap<String, String>,
extra_strip_names: &[String],
cmd_parts: &[String],
) -> SafeResult<Command> {
if cmd_parts.is_empty() {
return Err(SafeError::InvalidVault {
reason: "no command provided for exec".into(),
});
}
let mut cmd = Command::new(&cmd_parts[0]);
cmd.args(&cmd_parts[1..]);
apply_exec_environment(&mut cmd, secrets, extra_strip_names);
Ok(cmd)
}
pub fn clean_env_command(
secrets: &HashMap<String, String>,
keep: &HashMap<String, String>,
cmd_parts: &[String],
) -> SafeResult<Command> {
if cmd_parts.is_empty() {
return Err(SafeError::InvalidVault {
reason: "no command provided for exec".into(),
});
}
let mut cmd = Command::new(&cmd_parts[0]);
cmd.args(&cmd_parts[1..]);
cmd.env_clear();
for (k, v) in keep {
cmd.env(k, v);
}
for (k, v) in secrets {
cmd.env(k, v);
}
Ok(cmd)
}
pub fn exec_with_secrets(
secrets: &HashMap<String, String>,
cmd_parts: &[String],
) -> SafeResult<i32> {
let mut cmd = command_with_secrets(secrets, cmd_parts)?;
let status = cmd.status()?;
Ok(status.code().unwrap_or(1))
}
pub fn exec_clean_env(
secrets: &HashMap<String, String>,
keep: &HashMap<String, String>,
cmd_parts: &[String],
) -> SafeResult<i32> {
let mut cmd = clean_env_command(secrets, keep, cmd_parts)?;
let status = cmd.status()?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn sample() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("ZZZ".into(), "z".into());
m.insert("AAA".into(), "a".into());
m.insert("MMM".into(), "m with \"quotes\"".into());
m
}
#[test]
fn format_toml_bare_keys_and_basic_string_values() {
let pairs = vec![
("ZZZ".to_string(), "z".to_string()),
("AAA".to_string(), "a".to_string()),
(
"MY_KEY".to_string(),
"val with \"quotes\" and \\backslash".to_string(),
),
];
let out = format_toml(&pairs);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "AAA = \"a\"");
assert_eq!(
lines[1],
r#"MY_KEY = "val with \"quotes\" and \\backslash""#
);
assert_eq!(lines[2], "ZZZ = \"z\"");
}
#[test]
fn format_toml_non_bare_key_is_quoted() {
let pairs = vec![
("my-key".to_string(), "v1".to_string()),
("my key".to_string(), "v2".to_string()),
("my.key".to_string(), "v3".to_string()),
];
let out = format_toml(&pairs);
assert!(out.contains("my-key = \"v1\""), "hyphen key must be bare");
assert!(
out.contains("\"my key\" = \"v2\""),
"space key must be quoted"
);
assert!(
out.contains("\"my.key\" = \"v3\""),
"dot key must be quoted"
);
}
#[test]
fn format_toml_empty_input_produces_empty_string() {
let pairs: Vec<(String, String)> = vec![];
assert_eq!(format_toml(&pairs), "");
}
#[test]
fn format_env_sorted_output() {
let out = format_env(&sample());
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "AAA=a");
assert!(lines[2].starts_with("ZZZ="));
}
#[test]
fn format_json_valid() {
let json = format_json(&sample()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["AAA"], "a");
}
#[test]
fn format_env_escapes_newlines_and_carriage_returns() {
let mut secrets = HashMap::new();
secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
assert_eq!(format_env(&secrets), "MULTI=line1\\nline2\\rline3");
}
#[test]
fn format_github_actions_escapes_newlines_and_carriage_returns() {
let mut secrets = HashMap::new();
secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
let output = format_github_actions(&secrets);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "::add-mask::line1%0Aline2%0Dline3");
assert_eq!(lines[1], "MULTI=line1%0Aline2%0Dline3");
}
#[test]
fn dangerous_injected_env_names_detected_case_insensitive() {
assert!(is_dangerous_injected_env_name("NODE_OPTIONS"));
assert!(is_dangerous_injected_env_name("node_options"));
assert!(!is_dangerous_injected_env_name("API_KEY"));
}
#[test]
fn apply_exec_environment_removes_sensitive_vars_and_injects_secrets() {
let mut secrets = HashMap::new();
secrets.insert("APP_TOKEN".into(), "value-123".into());
let mut cmd = Command::new("placeholder");
apply_exec_environment(&mut cmd, &secrets, &[]);
let envs: HashMap<String, Option<String>> = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.map(|item| item.to_string_lossy().into_owned()),
)
})
.collect();
assert_eq!(envs.get("APP_TOKEN"), Some(&Some("value-123".into())));
for var in SENSITIVE_VARS {
assert_eq!(
envs.get(*var),
Some(&None),
"expected '{var}' to be removed from child environment"
);
}
}
#[test]
fn apply_exec_environment_removes_extra_strip_vars_even_when_not_globally_sensitive() {
let mut secrets = HashMap::new();
secrets.insert("GH_TOKEN".into(), "vault-gh-token".into());
let mut cmd = Command::new("placeholder");
apply_exec_environment(
&mut cmd,
&secrets,
&["DOCKER_PASSWORD".to_string(), "TWINE_PASSWORD".to_string()],
);
let envs: HashMap<String, Option<String>> = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.map(|item| item.to_string_lossy().into_owned()),
)
})
.collect();
assert_eq!(envs.get("GH_TOKEN"), Some(&Some("vault-gh-token".into())));
assert_eq!(envs.get("DOCKER_PASSWORD"), Some(&None));
assert_eq!(envs.get("TWINE_PASSWORD"), Some(&None));
}
#[test]
fn parse_dotenv_all_forms() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "# comment").unwrap();
writeln!(f, "K1=plain").unwrap();
writeln!(f, "K2=\"double\"").unwrap();
writeln!(f, "K3='single'").unwrap();
writeln!(f, "K4 = spaced ").unwrap();
writeln!(f, "export K5=bash").unwrap();
writeln!(f, "$K6 = powershell").unwrap();
writeln!(f, "SECTION_HEADER").unwrap(); writeln!(f).unwrap(); let m = parse_dotenv(f.path()).unwrap();
assert_eq!(m["K1"], "plain");
assert_eq!(m["K2"], "double");
assert_eq!(m["K3"], "single");
assert_eq!(m["K4"], "spaced");
assert_eq!(m["K5"], "bash");
assert_eq!(m["K6"], "powershell");
assert!(!m.contains_key("#"));
assert!(!m.contains_key("SECTION_HEADER"));
}
#[test]
fn parse_dotenv_no_eq_is_skipped() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "NOEQUALS").unwrap();
writeln!(f, "KEY=value").unwrap();
let m = parse_dotenv(f.path()).unwrap();
assert_eq!(m["KEY"], "value");
assert!(!m.contains_key("NOEQUALS"));
}
#[test]
fn parse_dotenv_file_not_found() {
let result = parse_dotenv(Path::new("/tmp/tsafe-nonexistent-9999.env"));
assert!(matches!(result, Err(SafeError::ImportParse { .. })));
}
#[test]
fn parse_dotenv_env_var_reference_is_resolved() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, r#"K_BARE=$TSAFE_TEST_RESOLVE_VAR"#).unwrap();
writeln!(f, r#"K_DOUBLE="$TSAFE_TEST_RESOLVE_VAR""#).unwrap();
writeln!(f, r#"K_SINGLE='$TSAFE_TEST_RESOLVE_VAR'"#).unwrap();
writeln!(f, r#"export ARM_CLIENT_ID="4a71128e-real-uuid""#).unwrap();
writeln!(f, r#"client_id="$ARM_CLIENT_ID""#).unwrap();
writeln!(f, r#"K_UNSET=$TSAFE_TEST_NO_SUCH_VAR_XYZ"#).unwrap();
let m = temp_env::with_var("TSAFE_TEST_RESOLVE_VAR", Some("resolved-value-abc"), || {
parse_dotenv(f.path()).unwrap()
});
assert_eq!(
m["K_BARE"], "resolved-value-abc",
"bare $VAR from env should resolve"
);
assert_eq!(
m["K_DOUBLE"], "resolved-value-abc",
"double-quoted $VAR from env should resolve"
);
assert_eq!(
m["K_SINGLE"], "resolved-value-abc",
"single-quoted $VAR from env should resolve"
);
assert_eq!(
m["ARM_CLIENT_ID"], "4a71128e-real-uuid",
"literal should be stored as-is"
);
assert_eq!(
m["client_id"], "4a71128e-real-uuid",
"$VAR intra-file reference should resolve"
);
assert_eq!(
m["K_UNSET"], "$TSAFE_TEST_NO_SUCH_VAR_XYZ",
"unset $VAR kept as literal"
);
}
}