Skip to main content

rsigma_runtime/sources/
template.rs

1//! Template expansion for dynamic pipeline sources.
2//!
3//! Walks a pipeline's fields and replaces `${source.X}` and `${source.X.path.to.field}`
4//! references with resolved data from the source resolution map.
5
6use std::collections::HashMap;
7
8use regex::Regex;
9use rsigma_eval::Pipeline;
10use std::sync::LazyLock;
11
12static SOURCE_TEMPLATE_RE: LazyLock<Regex> =
13    LazyLock::new(|| Regex::new(r"\$\{source\.([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\}").unwrap());
14
15/// Expands `${source.*}` template references in a pipeline using resolved source data.
16pub struct TemplateExpander;
17
18impl TemplateExpander {
19    /// Expand all `${source.*}` references in the pipeline's vars and return an updated pipeline.
20    ///
21    /// The pipeline's `vars` values are expanded by replacing template expressions
22    /// with data from the `resolved` map. Transformation fields containing templates
23    /// are left in place (they are handled at apply-time, not here) since transformations
24    /// use typed structures rather than raw strings.
25    pub fn expand(pipeline: &Pipeline, resolved: &HashMap<String, serde_json::Value>) -> Pipeline {
26        let mut expanded = pipeline.clone();
27
28        // Expand vars
29        for (_var_name, values) in expanded.vars.iter_mut() {
30            let mut new_values = Vec::new();
31            for val in values.iter() {
32                if let Some(expanded_vals) = Self::expand_string_value(val, resolved) {
33                    new_values.extend(expanded_vals);
34                } else {
35                    new_values.push(val.clone());
36                }
37            }
38            *values = new_values;
39        }
40
41        expanded
42    }
43
44    /// Try to expand a single string value containing `${source.*}` templates.
45    ///
46    /// Returns `None` if the string contains no templates.
47    /// Returns `Some(vec)` with the expanded values if templates were found.
48    fn expand_string_value(
49        value: &str,
50        resolved: &HashMap<String, serde_json::Value>,
51    ) -> Option<Vec<String>> {
52        if !value.contains("${source.") {
53            return None;
54        }
55
56        // If the entire value is a single template reference, replace it directly
57        if let Some(caps) = SOURCE_TEMPLATE_RE.captures(value)
58            && caps.get(0).unwrap().as_str() == value
59        {
60            let source_id = caps.get(1).unwrap().as_str();
61            let sub_path = caps.get(2).map(|m| m.as_str());
62
63            if let Some(data) = resolved.get(source_id) {
64                let target = if let Some(path) = sub_path {
65                    navigate_path(data, path)
66                } else {
67                    Some(data)
68                };
69
70                if let Some(val) = target {
71                    return Some(json_to_string_vec(val));
72                }
73            }
74
75            return None;
76        }
77
78        // Otherwise, do substring replacement (inline templates within larger strings)
79        let result = SOURCE_TEMPLATE_RE
80            .replace_all(value, |caps: &regex::Captures| {
81                let source_id = caps.get(1).unwrap().as_str();
82                let sub_path = caps.get(2).map(|m| m.as_str());
83
84                if let Some(data) = resolved.get(source_id) {
85                    let target = if let Some(path) = sub_path {
86                        navigate_path(data, path)
87                    } else {
88                        Some(data)
89                    };
90
91                    if let Some(val) = target {
92                        return json_to_single_string(val);
93                    }
94                }
95
96                caps.get(0).unwrap().as_str().to_string()
97            })
98            .to_string();
99
100        Some(vec![result])
101    }
102}
103
104/// Navigate a dot-separated path into a JSON value.
105///
106/// E.g., `"field_mapping.sysmon"` navigates `data["field_mapping"]["sysmon"]`.
107fn navigate_path<'a>(data: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
108    let mut current = data;
109    for segment in path.split('.') {
110        match current {
111            serde_json::Value::Object(map) => {
112                current = map.get(segment)?;
113            }
114            serde_json::Value::Array(arr) => {
115                let idx: usize = segment.parse().ok()?;
116                current = arr.get(idx)?;
117            }
118            _ => return None,
119        }
120    }
121    Some(current)
122}
123
124/// Convert a JSON value to a vector of strings for use in pipeline vars.
125///
126/// Arrays are flattened into multiple string entries.
127/// Objects are serialized as JSON strings.
128/// Scalars become single-element vectors.
129fn json_to_string_vec(val: &serde_json::Value) -> Vec<String> {
130    match val {
131        serde_json::Value::Array(arr) => arr.iter().map(json_to_single_string).collect(),
132        serde_json::Value::Null => vec![],
133        other => vec![json_to_single_string(other)],
134    }
135}
136
137/// Convert a single JSON value to a string representation.
138fn json_to_single_string(val: &serde_json::Value) -> String {
139    match val {
140        serde_json::Value::String(s) => s.clone(),
141        serde_json::Value::Null => String::new(),
142        serde_json::Value::Bool(b) => b.to_string(),
143        serde_json::Value::Number(n) => n.to_string(),
144        other => other.to_string(),
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn expand_simple_var() {
154        let mut vars = HashMap::new();
155        vars.insert(
156            "admin_emails".to_string(),
157            vec!["${source.admin_emails}".to_string()],
158        );
159
160        let pipeline = Pipeline {
161            name: "test".to_string(),
162            priority: 0,
163            vars,
164            transformations: vec![],
165            finalizers: vec![],
166            sources: vec![],
167            source_refs: vec![],
168        };
169
170        let mut resolved = HashMap::new();
171        resolved.insert(
172            "admin_emails".to_string(),
173            serde_json::json!(["admin@corp.com", "root@corp.com"]),
174        );
175
176        let expanded = TemplateExpander::expand(&pipeline, &resolved);
177        assert_eq!(
178            expanded.vars.get("admin_emails").unwrap(),
179            &vec!["admin@corp.com".to_string(), "root@corp.com".to_string()]
180        );
181    }
182
183    #[test]
184    fn expand_nested_path() {
185        let mut vars = HashMap::new();
186        vars.insert(
187            "log_index".to_string(),
188            vec!["${source.env_config.log_index}".to_string()],
189        );
190
191        let pipeline = Pipeline {
192            name: "test".to_string(),
193            priority: 0,
194            vars,
195            transformations: vec![],
196            finalizers: vec![],
197            sources: vec![],
198            source_refs: vec![],
199        };
200
201        let mut resolved = HashMap::new();
202        resolved.insert(
203            "env_config".to_string(),
204            serde_json::json!({"log_index": "security-events", "retention": "30d"}),
205        );
206
207        let expanded = TemplateExpander::expand(&pipeline, &resolved);
208        assert_eq!(
209            expanded.vars.get("log_index").unwrap(),
210            &vec!["security-events".to_string()]
211        );
212    }
213
214    #[test]
215    fn expand_inline_template() {
216        let mut vars = HashMap::new();
217        vars.insert(
218            "index_pattern".to_string(),
219            vec!["logs-${source.env_config.env}-*".to_string()],
220        );
221
222        let pipeline = Pipeline {
223            name: "test".to_string(),
224            priority: 0,
225            vars,
226            transformations: vec![],
227            finalizers: vec![],
228            sources: vec![],
229            source_refs: vec![],
230        };
231
232        let mut resolved = HashMap::new();
233        resolved.insert(
234            "env_config".to_string(),
235            serde_json::json!({"env": "production"}),
236        );
237
238        let expanded = TemplateExpander::expand(&pipeline, &resolved);
239        assert_eq!(
240            expanded.vars.get("index_pattern").unwrap(),
241            &vec!["logs-production-*".to_string()]
242        );
243    }
244
245    #[test]
246    fn static_vars_unchanged() {
247        let mut vars = HashMap::new();
248        vars.insert("static".to_string(), vec!["no_template_here".to_string()]);
249
250        let pipeline = Pipeline {
251            name: "test".to_string(),
252            priority: 0,
253            vars,
254            transformations: vec![],
255            finalizers: vec![],
256            sources: vec![],
257            source_refs: vec![],
258        };
259
260        let resolved = HashMap::new();
261        let expanded = TemplateExpander::expand(&pipeline, &resolved);
262        assert_eq!(
263            expanded.vars.get("static").unwrap(),
264            &vec!["no_template_here".to_string()]
265        );
266    }
267
268    #[test]
269    fn unresolved_template_kept_as_is() {
270        let mut vars = HashMap::new();
271        vars.insert(
272            "missing".to_string(),
273            vec!["${source.nonexistent}".to_string()],
274        );
275
276        let pipeline = Pipeline {
277            name: "test".to_string(),
278            priority: 0,
279            vars,
280            transformations: vec![],
281            finalizers: vec![],
282            sources: vec![],
283            source_refs: vec![],
284        };
285
286        let resolved = HashMap::new();
287        let expanded = TemplateExpander::expand(&pipeline, &resolved);
288        assert_eq!(
289            expanded.vars.get("missing").unwrap(),
290            &vec!["${source.nonexistent}".to_string()]
291        );
292    }
293}