use std::collections::HashMap;
use regex::Regex;
use rsigma_eval::Pipeline;
use std::sync::LazyLock;
static SOURCE_TEMPLATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$\{source\.([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\}").unwrap());
pub struct TemplateExpander;
impl TemplateExpander {
pub fn expand(pipeline: &Pipeline, resolved: &HashMap<String, serde_json::Value>) -> Pipeline {
let mut expanded = pipeline.clone();
for (_var_name, values) in expanded.vars.iter_mut() {
let mut new_values = Vec::new();
for val in values.iter() {
if let Some(expanded_vals) = Self::expand_string_value(val, resolved) {
new_values.extend(expanded_vals);
} else {
new_values.push(val.clone());
}
}
*values = new_values;
}
expanded
}
fn expand_string_value(
value: &str,
resolved: &HashMap<String, serde_json::Value>,
) -> Option<Vec<String>> {
if !value.contains("${source.") {
return None;
}
if let Some(caps) = SOURCE_TEMPLATE_RE.captures(value)
&& caps.get(0).unwrap().as_str() == value
{
let source_id = caps.get(1).unwrap().as_str();
let sub_path = caps.get(2).map(|m| m.as_str());
if let Some(data) = resolved.get(source_id) {
let target = if let Some(path) = sub_path {
navigate_path(data, path)
} else {
Some(data)
};
if let Some(val) = target {
return Some(json_to_string_vec(val));
}
}
return None;
}
let result = SOURCE_TEMPLATE_RE
.replace_all(value, |caps: ®ex::Captures| {
let source_id = caps.get(1).unwrap().as_str();
let sub_path = caps.get(2).map(|m| m.as_str());
if let Some(data) = resolved.get(source_id) {
let target = if let Some(path) = sub_path {
navigate_path(data, path)
} else {
Some(data)
};
if let Some(val) = target {
return json_to_single_string(val);
}
}
caps.get(0).unwrap().as_str().to_string()
})
.to_string();
Some(vec![result])
}
}
fn navigate_path<'a>(data: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut current = data;
for segment in path.split('.') {
match current {
serde_json::Value::Object(map) => {
current = map.get(segment)?;
}
serde_json::Value::Array(arr) => {
let idx: usize = segment.parse().ok()?;
current = arr.get(idx)?;
}
_ => return None,
}
}
Some(current)
}
fn json_to_string_vec(val: &serde_json::Value) -> Vec<String> {
match val {
serde_json::Value::Array(arr) => arr.iter().map(json_to_single_string).collect(),
serde_json::Value::Null => vec![],
other => vec![json_to_single_string(other)],
}
}
fn json_to_single_string(val: &serde_json::Value) -> String {
match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_simple_var() {
let mut vars = HashMap::new();
vars.insert(
"admin_emails".to_string(),
vec!["${source.admin_emails}".to_string()],
);
let pipeline = Pipeline {
name: "test".to_string(),
priority: 0,
vars,
transformations: vec![],
finalizers: vec![],
sources: vec![],
source_refs: vec![],
};
let mut resolved = HashMap::new();
resolved.insert(
"admin_emails".to_string(),
serde_json::json!(["admin@corp.com", "root@corp.com"]),
);
let expanded = TemplateExpander::expand(&pipeline, &resolved);
assert_eq!(
expanded.vars.get("admin_emails").unwrap(),
&vec!["admin@corp.com".to_string(), "root@corp.com".to_string()]
);
}
#[test]
fn expand_nested_path() {
let mut vars = HashMap::new();
vars.insert(
"log_index".to_string(),
vec!["${source.env_config.log_index}".to_string()],
);
let pipeline = Pipeline {
name: "test".to_string(),
priority: 0,
vars,
transformations: vec![],
finalizers: vec![],
sources: vec![],
source_refs: vec![],
};
let mut resolved = HashMap::new();
resolved.insert(
"env_config".to_string(),
serde_json::json!({"log_index": "security-events", "retention": "30d"}),
);
let expanded = TemplateExpander::expand(&pipeline, &resolved);
assert_eq!(
expanded.vars.get("log_index").unwrap(),
&vec!["security-events".to_string()]
);
}
#[test]
fn expand_inline_template() {
let mut vars = HashMap::new();
vars.insert(
"index_pattern".to_string(),
vec!["logs-${source.env_config.env}-*".to_string()],
);
let pipeline = Pipeline {
name: "test".to_string(),
priority: 0,
vars,
transformations: vec![],
finalizers: vec![],
sources: vec![],
source_refs: vec![],
};
let mut resolved = HashMap::new();
resolved.insert(
"env_config".to_string(),
serde_json::json!({"env": "production"}),
);
let expanded = TemplateExpander::expand(&pipeline, &resolved);
assert_eq!(
expanded.vars.get("index_pattern").unwrap(),
&vec!["logs-production-*".to_string()]
);
}
#[test]
fn static_vars_unchanged() {
let mut vars = HashMap::new();
vars.insert("static".to_string(), vec!["no_template_here".to_string()]);
let pipeline = Pipeline {
name: "test".to_string(),
priority: 0,
vars,
transformations: vec![],
finalizers: vec![],
sources: vec![],
source_refs: vec![],
};
let resolved = HashMap::new();
let expanded = TemplateExpander::expand(&pipeline, &resolved);
assert_eq!(
expanded.vars.get("static").unwrap(),
&vec!["no_template_here".to_string()]
);
}
#[test]
fn unresolved_template_kept_as_is() {
let mut vars = HashMap::new();
vars.insert(
"missing".to_string(),
vec!["${source.nonexistent}".to_string()],
);
let pipeline = Pipeline {
name: "test".to_string(),
priority: 0,
vars,
transformations: vec![],
finalizers: vec![],
sources: vec![],
source_refs: vec![],
};
let resolved = HashMap::new();
let expanded = TemplateExpander::expand(&pipeline, &resolved);
assert_eq!(
expanded.vars.get("missing").unwrap(),
&vec!["${source.nonexistent}".to_string()]
);
}
}