use anyhow::Result;
use std::path::PathBuf;
use super::env_cache::GLOBAL_ENV_CACHE;
pub(super) fn expand_path_internal(path: &str) -> Result<PathBuf> {
let path = if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(stripped)
} else {
PathBuf::from(path)
}
} else {
PathBuf::from(path)
};
let path_str = path.to_string_lossy();
if path_str.contains('$') {
match secure_expand_environment_variables(&path_str) {
Ok(expanded) => Ok(PathBuf::from(expanded)),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("Security violation") {
Err(e.context("Environment variable expansion security violation"))
} else {
tracing::warn!(
"Environment variable expansion failed for '{}': {}. Using original path.",
path_str,
e
);
Ok(path)
}
}
}
} else {
Ok(path)
}
}
fn secure_expand_environment_variables(input: &str) -> Result<String> {
const MAX_EXPANSION_DEPTH: usize = 5;
let safe_variables = std::collections::HashSet::from([
"HOME",
"USER",
"LOGNAME",
"USERNAME",
"SSH_AUTH_SOCK",
"SSH_CONNECTION",
"SSH_CLIENT",
"SSH_TTY",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"TMPDIR",
"TEMP",
"TMP",
"TERM",
"COLORTERM",
]);
let dangerous_variables = std::collections::HashSet::from([
"PATH",
"LD_LIBRARY_PATH",
"LD_PRELOAD",
"DYLD_LIBRARY_PATH",
"PYTHONPATH",
"PERL5LIB",
"RUBYLIB",
"CLASSPATH",
"IFS",
"PS1",
"PS2",
"PS4",
"PROMPT_COMMAND",
"SHELL",
"BASH_ENV",
"ENV",
"FCEDIT",
"FPATH",
"CDPATH",
"GLOBIGNORE",
"HISTFILE",
"HISTSIZE",
"MAILCHECK",
"MAILPATH",
"MANPATH",
]);
let mut result = input.to_string();
let mut expansion_depth = 0;
let mut changed = true;
while changed && expansion_depth < MAX_EXPANSION_DEPTH {
changed = false;
expansion_depth += 1;
let mut vars_to_expand = Vec::new();
let mut pos = 0;
while let Some(start) = result[pos..].find("${") {
let abs_start = pos + start;
if let Some(end) = result[abs_start + 2..].find('}') {
let abs_end = abs_start + 2 + end;
let var_name = &result[abs_start + 2..abs_end];
vars_to_expand.push((abs_start, abs_end + 1, var_name.to_string(), true));
pos = abs_end + 1;
} else {
anyhow::bail!(
"Security violation: Unclosed brace in environment variable expansion. \
This could indicate an injection attempt."
);
}
}
pos = 0;
while let Some(start) = result[pos..].find('$') {
let abs_start = pos + start;
if abs_start + 1 < result.len()
&& !result
.chars()
.nth(abs_start + 1)
.unwrap()
.is_ascii_alphabetic()
{
pos = abs_start + 1;
continue;
}
let var_start = abs_start + 1;
let var_end = result[var_start..]
.find(|c: char| !c.is_alphanumeric() && c != '_')
.map(|i| var_start + i)
.unwrap_or(result.len());
if var_start < var_end {
let var_name = &result[var_start..var_end];
if !vars_to_expand
.iter()
.any(|(_, _, name, _)| name == var_name)
{
vars_to_expand.push((abs_start, var_end, var_name.to_string(), false));
}
}
pos = var_end.max(abs_start + 1);
}
vars_to_expand.sort_by(|a, b| b.0.cmp(&a.0));
for (start_pos, end_pos, var_name, is_braced) in vars_to_expand {
if dangerous_variables.contains(var_name.as_str()) {
tracing::warn!(
"Blocked expansion of dangerous environment variable '{}'. \
This variable could be used for injection attacks.",
var_name
);
anyhow::bail!(
"Security violation: Attempted to expand dangerous environment variable '{var_name}'. \
Variables like PATH, LD_LIBRARY_PATH, LD_PRELOAD are not allowed for security reasons."
);
}
if !safe_variables.contains(var_name.as_str()) {
tracing::warn!(
"Blocked expansion of non-whitelisted environment variable '{}'. \
Only specific safe variables are allowed.",
var_name
);
continue;
}
match GLOBAL_ENV_CACHE.get_env_var(&var_name) {
Ok(Some(var_value)) => {
let sanitized_value = sanitize_environment_value(&var_value, &var_name)?;
result.replace_range(start_pos..end_pos, &sanitized_value);
changed = true;
tracing::debug!(
"Expanded environment variable '{}' (length: {}) in path expansion",
var_name,
sanitized_value.len()
);
}
Ok(None) => {
if is_braced {
result.replace_range(start_pos..end_pos, "");
changed = true;
}
}
Err(e) => {
tracing::warn!(
"Failed to get environment variable '{}' from cache: {}. Skipping expansion.",
var_name,
e
);
}
}
}
}
if expansion_depth >= MAX_EXPANSION_DEPTH {
anyhow::bail!(
"Security violation: Environment variable expansion depth limit exceeded ({MAX_EXPANSION_DEPTH} levels). \
This could indicate a recursive expansion attack."
);
}
validate_expanded_path_content(&result)?;
Ok(result)
}
fn sanitize_environment_value(value: &str, var_name: &str) -> Result<String> {
const MAX_VALUE_LENGTH: usize = 4096;
if value.len() > MAX_VALUE_LENGTH {
anyhow::bail!(
"Security violation: Environment variable '{}' value is too long ({} bytes). \
Maximum allowed length is {} bytes to prevent DoS attacks.",
var_name,
value.len(),
MAX_VALUE_LENGTH
);
}
if value.contains('\0') {
anyhow::bail!(
"Security violation: Environment variable '{var_name}' contains null byte. \
This could be used for path truncation attacks."
);
}
const DANGEROUS_CHARS: &[char] = &[';', '&', '|', '`', '\n', '\r'];
if let Some(dangerous_char) = value.chars().find(|c| DANGEROUS_CHARS.contains(c)) {
anyhow::bail!(
"Security violation: Environment variable '{var_name}' contains dangerous character '{dangerous_char}'. \
This could enable command injection attacks."
);
}
if value.contains("$(") || value.contains("${") {
anyhow::bail!(
"Security violation: Environment variable '{var_name}' contains command substitution pattern. \
This could enable command injection attacks."
);
}
if value.contains("../") || value.contains("..\\") {
match var_name {
"HOME" | "TMPDIR" | "TEMP" | "TMP" => {
tracing::warn!(
"Environment variable '{}' contains path traversal sequence '{}'. \
This may be legitimate for system variables but could indicate an attack.",
var_name,
value
);
}
_ => {
anyhow::bail!(
"Security violation: Environment variable '{var_name}' contains path traversal sequence. \
This could enable directory traversal attacks."
);
}
}
}
match var_name {
"SSH_AUTH_SOCK" => {
if !value.starts_with('/') && !value.starts_with("./") {
tracing::warn!(
"SSH_AUTH_SOCK '{}' does not look like a typical socket path",
value
);
}
}
"HOME" => {
if !value.starts_with('/') && !value.contains(":\\") {
tracing::warn!(
"HOME '{}' does not look like a typical home directory path",
value
);
}
}
_ => {}
}
Ok(value.to_string())
}
fn validate_expanded_path_content(expanded: &str) -> Result<()> {
if expanded.contains("$(") || expanded.contains("`") {
anyhow::bail!(
"Security violation: Expanded path still contains command substitution patterns. \
This could indicate a sophisticated injection attempt."
);
}
if expanded.contains("//") && !expanded.starts_with("http") {
tracing::debug!(
"Expanded path contains multiple consecutive slashes: '{}'",
expanded
);
}
const MAX_PATH_LENGTH: usize = 4096;
if expanded.len() > MAX_PATH_LENGTH {
anyhow::bail!(
"Security violation: Expanded path is too long ({} characters). \
Maximum allowed length is {} characters.",
expanded.len(),
MAX_PATH_LENGTH
);
}
if expanded.chars().any(|c| c.is_control() && c != '\t') {
anyhow::bail!(
"Security violation: Expanded path contains control characters. \
This could be used for terminal escape sequence attacks."
);
}
Ok(())
}