use regex::Regex;
use std::collections::HashMap;
use std::process::Stdio;
use tokio::process::Command;
use crate::sanitize::sanitize_output;
pub struct RunResult {
pub exit_code: i32,
pub output: String, }
#[cfg(unix)]
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[cfg(not(unix))]
fn shell_quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
fn substitute_secrets(command: &str, secrets: &HashMap<String, String>) -> Result<(String, HashMap<String, String>), String> {
let re = Regex::new(r"\$env\[([^\]]+)\]").unwrap();
let mut result = command.to_string();
let mut used_secrets = HashMap::new();
let matches: Vec<_> = re.captures_iter(command).collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let secret_name = cap.get(1).unwrap().as_str();
match secrets.get(secret_name) {
Some(value) => {
used_secrets.insert(secret_name.to_string(), value.clone());
result.replace_range(full_match.range(), &shell_quote(value));
}
None => {
return Err(format!("Secret not found: {}", secret_name));
}
}
}
Ok((result, used_secrets))
}
pub async fn run_with_secrets(
command: &str,
working_dir: Option<&str>,
all_secrets: &HashMap<String, String>,
) -> Result<RunResult, String> {
let (substituted_cmd, _used_secrets) = substitute_secrets(command, all_secrets)?;
tracing::info!("Running command (secrets substituted)");
#[cfg(unix)]
let mut cmd = {
let mut c = Command::new("sh");
c.arg("-c").arg(&substituted_cmd);
c
};
#[cfg(not(unix))]
let mut cmd = {
use std::os::windows::process::CommandExt;
let mut c = Command::new("cmd");
c.arg("/c");
c.as_std_mut().raw_arg(&substituted_cmd);
c
};
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let output = cmd.output().await
.map_err(|e| format!("Failed to run command: {}", e))?;
let exit_code = output.status.code().unwrap_or(-1);
let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
combined.push_str("\n[stderr]\n");
combined.push_str(&stderr);
}
let dev_mode = std::env::var("SCRT4_DEV_MODE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let output_str = if dev_mode {
combined
} else {
sanitize_output(&combined, all_secrets)
};
Ok(RunResult {
exit_code,
output: output_str,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute_single() {
let mut secrets = HashMap::new();
secrets.insert("KEY".to_string(), "value123".to_string());
let (result, used) = substitute_secrets("echo $env[KEY]", &secrets).unwrap();
#[cfg(unix)]
assert_eq!(result, "echo 'value123'");
#[cfg(not(unix))]
assert_eq!(result, "echo \"value123\"");
assert_eq!(used.get("KEY"), Some(&"value123".to_string()));
}
#[test]
fn test_substitute_multiple() {
let mut secrets = HashMap::new();
secrets.insert("A".to_string(), "aaa".to_string());
secrets.insert("B".to_string(), "bbb".to_string());
let (result, _) = substitute_secrets("$env[A] and $env[B]", &secrets).unwrap();
#[cfg(unix)]
assert_eq!(result, "'aaa' and 'bbb'");
#[cfg(not(unix))]
assert_eq!(result, "\"aaa\" and \"bbb\"");
}
#[test]
fn test_substitute_missing() {
let secrets = HashMap::new();
let result = substitute_secrets("$env[MISSING]", &secrets);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Secret not found: MISSING"));
}
#[test]
fn test_substitute_no_secrets() {
let secrets = HashMap::new();
let (result, used) = substitute_secrets("echo hello", &secrets).unwrap();
assert_eq!(result, "echo hello");
assert!(used.is_empty());
}
#[test]
fn test_substitute_adjacent() {
let mut secrets = HashMap::new();
secrets.insert("USER".to_string(), "admin".to_string());
secrets.insert("PASS".to_string(), "secret".to_string());
let (result, _) = substitute_secrets("$env[USER]:$env[PASS]", &secrets).unwrap();
#[cfg(unix)]
assert_eq!(result, "'admin':'secret'");
#[cfg(not(unix))]
assert_eq!(result, "\"admin\":\"secret\"");
}
#[test]
fn test_substitute_shell_metacharacters() {
let mut secrets = HashMap::new();
secrets.insert("PASS".to_string(), "h3llo W*rld!".to_string());
let (result, _) = substitute_secrets("VAR=$env[PASS] cmd", &secrets).unwrap();
#[cfg(unix)]
assert_eq!(result, "VAR='h3llo W*rld!' cmd");
#[cfg(not(unix))]
assert_eq!(result, "VAR=\"h3llo W*rld!\" cmd");
}
#[test]
#[cfg(unix)]
fn test_substitute_embedded_single_quote() {
let mut secrets = HashMap::new();
secrets.insert("VAL".to_string(), "it's here".to_string());
let (result, _) = substitute_secrets("echo $env[VAL]", &secrets).unwrap();
assert_eq!(result, "echo 'it'\\''s here'");
}
#[test]
#[cfg(not(unix))]
fn test_substitute_embedded_double_quote() {
let mut secrets = HashMap::new();
secrets.insert("VAL".to_string(), "say \"hi\" now".to_string());
let (result, _) = substitute_secrets("echo $env[VAL]", &secrets).unwrap();
assert_eq!(result, "echo \"say \"\"hi\"\" now\"");
}
}