use std::{
collections::HashMap,
fs,
path::PathBuf,
};
use crate::request::{TemplateError, VariableStore};
use super::{
context,
spans::prompt_error_to_template_error,
};
fn inherits_sensitive_value(store: &VariableStore, raw_value: &str) -> bool {
context::extract_placeholders(raw_value)
.into_iter()
.any(|placeholder| store.is_sensitive_scalar(&placeholder))
}
pub fn eval_shell_script(
script: &str,
working_dir: &PathBuf,
env: Option<HashMap<String, String>>,
) -> String {
let env = env.unwrap_or_default();
log::debug!("evaluating shell script: {}", script);
log::debug!("using directory {:?}", working_dir);
let output = std::process::Command::new("sh")
.current_dir(working_dir)
.arg("-c")
.envs(env)
.arg(script)
.output()
.expect("failed to execute process");
String::from_utf8(output.stdout).unwrap()
}
#[derive(Debug, Clone)]
enum SecretReference {
Env { name: String },
File { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum SecretCacheKey {
Env(String),
File(PathBuf),
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SecretValueCache {
values: HashMap<SecretCacheKey, String>,
}
impl SecretValueCache {
fn get(&self, key: &SecretCacheKey) -> Option<String> {
self.values.get(key).cloned()
}
fn insert(&mut self, key: SecretCacheKey, value: String) -> String {
self.values.insert(key, value.clone());
value
}
}
pub(crate) fn resolve_runtime_scalar(
source: &str,
raw_value: &str,
context_map: &HashMap<String, String>,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
) -> Result<String, TemplateError> {
let trimmed = raw_value.trim();
if let Some(secret) = parse_secret_reference(source, trimmed)? {
return resolve_secret_reference(source, &secret, working_dir, secret_cache);
}
let resolved = context::resolve_with_context(trimmed, context_map);
context::try_inject_from_prompt(&resolved)
.map_err(|err| prompt_error_to_template_error(source, err))
}
pub(super) fn assign_global_variable_for_validation(
store: &mut VariableStore,
key: String,
raw_value: &str,
) -> Result<(), TemplateError> {
let label = format!("${}", key);
let trimmed = raw_value.trim();
let empty_context = HashMap::new();
if trimmed.starts_with("$(") {
store.set_scalar(key.clone(), syntax_placeholder_value(key.as_str()));
return Ok(());
}
if let Some(_) = parse_secret_reference(&label, trimmed)? {
store.set_scalar(key, trimmed.to_string());
return Ok(());
}
if is_array_literal(trimmed) {
let values = parse_array_literal(trimmed, &label, &key, |value| {
Ok(context::inject_from_variable(value, &empty_context))
})?;
store.set_array(key, values);
return Ok(());
}
let value = context::inject_from_variable(trimmed, &empty_context);
store.set_scalar(key, value);
Ok(())
}
pub(super) fn assign_request_variable_for_validation(
store: &mut VariableStore,
key: String,
raw_value: &str,
) -> Result<(), TemplateError> {
let label = format!("request variable ${}", key);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") {
store.set_scalar(key.clone(), syntax_placeholder_value(key.as_str()));
return Ok(());
}
if let Some(_) = parse_secret_reference(&label, trimmed)? {
store.set_scalar(key, trimmed.to_string());
return Ok(());
}
let current_scalars = store.clone_scalars();
if is_array_literal(trimmed) {
let values = parse_array_literal(trimmed, &label, &key, |value| {
Ok(context::inject_from_variable(value, ¤t_scalars))
})?;
store.set_array(key, values);
return Ok(());
}
let value = context::inject_from_variable(trimmed, ¤t_scalars);
store.set_scalar(key, value);
Ok(())
}
pub(super) fn assign_environment_variable_for_validation(
store: &mut VariableStore,
environment: &str,
key: String,
raw_value: &str,
) -> Result<(), TemplateError> {
let source = format!("environment '{}'", environment);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") || is_array_literal(trimmed) {
return Err(TemplateError::InvalidEnvironmentValue {
environment: environment.to_string(),
variable: key,
value: raw_value.trim().to_string(),
});
}
if let Some(_) = parse_secret_reference(&source, trimmed)? {
store.set_scalar(key, trimmed.to_string());
return Ok(());
}
let current_scalars = store.clone_scalars();
let value = context::inject_from_variable(trimmed, ¤t_scalars);
store.set_scalar(key, value);
Ok(())
}
fn syntax_placeholder_value(key: &str) -> String {
format!("__hen_syntax_{}__", key)
}
pub(super) fn assign_global_variable(
store: &mut VariableStore,
key: String,
raw_value: &str,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
) -> Result<(), TemplateError> {
let label = format!("${}", key);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") {
let script = trimmed.trim_start_matches("$(").trim_end_matches(")");
let value = eval_shell_script(script, working_dir, None)
.trim()
.to_string();
store.set_scalar(key, value);
return Ok(());
}
if let Some(secret) = parse_secret_reference(&label, trimmed)? {
let value = resolve_secret_reference(&label, &secret, working_dir, secret_cache)?;
store.set_scalar_with_sensitivity(key, value, true);
return Ok(());
}
if is_array_literal(trimmed) {
let values = parse_array_literal(trimmed, &label, &key, |value| {
context::try_inject_from_prompt(value)
.map_err(|err| prompt_error_to_template_error(&label, err))
})?;
store.set_array(key, values);
return Ok(());
}
let value = context::try_inject_from_prompt(trimmed)
.map_err(|err| prompt_error_to_template_error(&label, err))?;
store.set_scalar_with_sensitivity(key, value, inherits_sensitive_value(store, trimmed));
Ok(())
}
pub(super) fn assign_environment_variable(
store: &mut VariableStore,
environment: &str,
key: String,
raw_value: &str,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
) -> Result<(), TemplateError> {
let label = format!("environment '{}'", environment);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") || is_array_literal(trimmed) {
return Err(TemplateError::InvalidEnvironmentValue {
environment: environment.to_string(),
variable: key,
value: raw_value.trim().to_string(),
});
}
if let Some(secret) = parse_secret_reference(&label, trimmed)? {
let value = resolve_secret_reference(&label, &secret, working_dir, secret_cache)?;
store.set_scalar_with_sensitivity(key, value, true);
return Ok(());
}
let current_scalars = store.clone_scalars();
let value = context::inject_from_variable(trimmed, ¤t_scalars);
let value = context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_template_error(&label, err))?;
store.set_scalar_with_sensitivity(key, value, inherits_sensitive_value(store, trimmed));
Ok(())
}
pub(super) fn assign_global_variable_for_selected_environment_override(
store: &mut VariableStore,
key: String,
raw_value: &str,
) {
let trimmed = raw_value.trim();
if is_array_literal(trimmed) {
return;
}
let current_scalars = store.clone_scalars();
let value = context::inject_from_variable(trimmed, ¤t_scalars);
store.set_scalar_with_sensitivity(key, value, inherits_sensitive_value(store, trimmed));
}
pub(super) fn assign_request_variable(
store: &mut VariableStore,
key: String,
raw_value: &str,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
) -> Result<(), TemplateError> {
let label = format!("request variable ${}", key);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") {
let script = trimmed.trim_start_matches("$(").trim_end_matches(")");
let value = eval_shell_script(script, working_dir, None)
.trim()
.to_string();
store.set_scalar(key, value);
return Ok(());
}
if let Some(secret) = parse_secret_reference(&label, trimmed)? {
let value = resolve_secret_reference(&label, &secret, working_dir, secret_cache)?;
store.set_scalar_with_sensitivity(key, value, true);
return Ok(());
}
let current_scalars = store.clone_scalars();
if is_array_literal(trimmed) {
let values = parse_array_literal(trimmed, &label, &key, |value| {
Ok(context::inject_from_variable(value, ¤t_scalars))
})?;
store.set_array(key, values);
return Ok(());
}
let value = context::inject_from_variable(trimmed, ¤t_scalars);
store.set_scalar_with_sensitivity(key, value, inherits_sensitive_value(store, trimmed));
Ok(())
}
fn is_array_literal(raw: &str) -> bool {
let trimmed = raw.trim();
trimmed.starts_with('[')
&& trimmed.ends_with(']')
&& trimmed.len() >= 2
&& !is_standalone_prompt_placeholder(trimmed)
}
fn parse_secret_reference(
source: &str,
raw_value: &str,
) -> Result<Option<SecretReference>, TemplateError> {
let trimmed = raw_value.trim();
let Some(remainder) = trimmed.strip_prefix("secret.") else {
return Ok(None);
};
let Some(open_paren) = remainder.find('(') else {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: trimmed.to_string(),
});
};
if !trimmed.ends_with(')') {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: trimmed.to_string(),
});
}
let provider = remainder[..open_paren].trim();
let argument = &remainder[open_paren + 1..remainder.len() - 1];
let argument = parse_secret_string_literal(source, trimmed, argument)?;
match provider {
"env" => Ok(Some(SecretReference::Env { name: argument })),
"file" => Ok(Some(SecretReference::File { path: argument })),
_ => Err(TemplateError::UnsupportedSecretProvider {
source: source.to_string(),
provider: provider.to_string(),
}),
}
}
fn parse_secret_string_literal(
source: &str,
raw_value: &str,
argument: &str,
) -> Result<String, TemplateError> {
let argument = argument.trim();
let Some(quote) = argument.chars().next() else {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
});
};
if quote != '"' && quote != '\'' {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
});
}
if !argument.ends_with(quote) || argument.len() < 2 {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
});
}
let inner = &argument[1..argument.len() - 1];
let mut value = String::new();
let mut escaping = false;
for ch in inner.chars() {
if escaping {
let escaped = match ch {
'\\' => '\\',
'"' => '"',
'\'' => '\'',
'n' => '\n',
'r' => '\r',
't' => '\t',
_ => {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
})
}
};
value.push(escaped);
escaping = false;
continue;
}
if ch == '\\' {
escaping = true;
continue;
}
if ch == quote {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
});
}
value.push(ch);
}
if escaping || value.contains("[[") || value.contains("{{") {
return Err(TemplateError::InvalidSecretReference {
source: source.to_string(),
value: raw_value.to_string(),
});
}
Ok(value)
}
fn resolve_secret_reference(
source: &str,
reference: &SecretReference,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
) -> Result<String, TemplateError> {
match reference {
SecretReference::Env { name } => {
let cache_key = SecretCacheKey::Env(name.clone());
if let Some(value) = secret_cache.get(&cache_key) {
return Ok(value);
}
let value = std::env::var(name).map_err(|_| TemplateError::MissingEnvSecret {
source: source.to_string(),
name: name.clone(),
})?;
Ok(secret_cache.insert(cache_key, value))
}
SecretReference::File { path } => {
let resolved_path = {
let path_buf = PathBuf::from(path);
if path_buf.is_absolute() {
path_buf
} else {
working_dir.join(path_buf)
}
};
let cache_key = SecretCacheKey::File(resolved_path.clone());
if let Some(value) = secret_cache.get(&cache_key) {
return Ok(value);
}
let contents = fs::read_to_string(&resolved_path).map_err(|err| {
TemplateError::FileSecretIo {
source: source.to_string(),
path: resolved_path.display().to_string(),
reason: err.to_string(),
}
})?;
Ok(secret_cache.insert(
cache_key,
strip_single_trailing_line_ending(contents),
))
}
}
}
fn strip_single_trailing_line_ending(mut value: String) -> String {
if value.ends_with('\n') {
value.pop();
if value.ends_with('\r') {
value.pop();
}
}
value
}
fn is_standalone_prompt_placeholder(raw: &str) -> bool {
raw.strip_prefix("[[")
.and_then(|inner| inner.strip_suffix("]]"))
.map(|inner| !inner.contains('[') && !inner.contains(']'))
.unwrap_or(false)
}
fn parse_array_literal<F>(
raw: &str,
label: &str,
variable: &str,
mut transform: F,
) -> Result<Vec<String>, TemplateError>
where
F: FnMut(&str) -> Result<String, TemplateError>,
{
if !is_array_literal(raw) {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
let inner = &raw[1..raw.len() - 1];
if inner.trim().is_empty() {
return Err(TemplateError::EmptyArrayValues {
request: label.to_string(),
variable: variable.to_string(),
});
}
let mut segments: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
for ch in inner.chars() {
match ch {
'\'' => {
if !in_double {
in_single = !in_single;
}
current.push(ch);
}
'"' => {
if !in_single {
in_double = !in_double;
}
current.push(ch);
}
',' if !in_single && !in_double => {
if current.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
segments.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if in_single || in_double {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
if current.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
segments.push(current.trim().to_string());
let mut values = Vec::with_capacity(segments.len());
for segment in segments {
let normalized = normalize_array_element(segment.as_str());
if normalized.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
values.push(transform(normalized.trim())?);
}
Ok(values)
}
fn normalize_array_element(value: &str) -> String {
let trimmed = value.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
{
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}