Skip to main content

guard_core_engine/patterns/
templates.rs

1//! Template-injection scan matchers ported from
2//! `guard_core/handlers/_suspatterns_templates.py` (spec 4.0.2).
3//!
4//! Region scan between opening/closing delimiters (`{{ }}`, `${ }`, `{% %}`,
5//! `<% %>`, `#{ }`), not naive regex: an indicator must appear strictly inside
6//! the region, and `{{`/`#{` forms skip regions dominated by a date.
7//!
8//! The indicator sources embed a `(?<!\d)` guard in front of the literal
9//! arithmetic branch; the guard is enforced with a char check on the candidate
10//! start (a candidate beginning mid-number is rejected, mirroring the
11//! lookbehind exactly because every inner start of a digit run is also
12//! digit-preceded).
13
14use super::chars_util::{str_find_from, walk_forward_while};
15use super::pyregex::{Candidate, PyRegex};
16
17const KEYWORD_INDICATOR: &str = r"(?:system|exec|popen|eval|require|include)\s*\z";
18const DOLLAR_INDICATOR: &str = r"@[\w.]+@|\b\w+\s*\(";
19const CURLY_INDICATOR: &str = r"@[\w.]+@|\b\w+\(\s*\)";
20const HASH_INDICATOR: &str = r"@[\w.]+@|\b\w+\s*\(";
21const ASP_INDICATOR: &str = r"system|exec|eval|`|Runtime|IO\.|File\.|Dir\.";
22const ARITH_UNQUOTED: &str = r"\d+\s*[*/%+\-]\s*\d+";
23const ARITH_ASP: &str = r"\d+\s*[-+*/]\s*\d+";
24const ARITH_QUOTED: &str = r#"['"]?\d+['"]?\s*[*/%+\-]\s*['"]?\d+['"]?"#;
25const DATE_SOURCE: &str = r"\d{4}-\d{1,2}-\d{1,2}";
26
27pub struct TemplateKind {
28    pub opening: &'static str,
29    pub closing: &'static str,
30    /// indicator branches without the arithmetic alternative
31    pub indicator: &'static str,
32    /// arithmetic branch source (last alternative, carries the `(?<!\d)`
33    /// guard structurally)
34    pub arithmetic: &'static str,
35}
36
37pub const KIND_DOLLAR: TemplateKind = TemplateKind {
38    opening: "${",
39    closing: "}",
40    indicator: DOLLAR_INDICATOR,
41    arithmetic: ARITH_UNQUOTED,
42};
43pub const KIND_CURLY_CALL: TemplateKind = TemplateKind {
44    opening: "{{",
45    closing: "}}",
46    indicator: CURLY_INDICATOR,
47    arithmetic: ARITH_QUOTED,
48};
49pub const KIND_CURLY_KEYWORD: TemplateKind = TemplateKind {
50    opening: "{{",
51    closing: "}}",
52    indicator: KEYWORD_INDICATOR,
53    arithmetic: "",
54};
55pub const KIND_HASH: TemplateKind = TemplateKind {
56    opening: "#{",
57    closing: "}",
58    indicator: HASH_INDICATOR,
59    arithmetic: ARITH_QUOTED,
60};
61pub const KIND_PERCENT_KEYWORD: TemplateKind = TemplateKind {
62    opening: "{%",
63    closing: "%}",
64    indicator: KEYWORD_INDICATOR,
65    arithmetic: "",
66};
67pub const KIND_ASP: TemplateKind = TemplateKind {
68    opening: "<%",
69    closing: "%>",
70    indicator: ASP_INDICATOR,
71    arithmetic: ARITH_ASP,
72};
73
74pub struct TemplateRegion {
75    pub start: usize,
76    pub barrier: usize,
77    pub end: usize,
78}
79
80/// `_template_regions`: opening/closing delimiter pairs with the reference's
81/// cursor advance (`cursor = max(body_start, barrier - len(opening) + 1)`).
82#[must_use]
83pub fn template_regions(content: &str, opening: &str, closing: &str) -> Vec<TemplateRegion> {
84    let mut regions = Vec::new();
85    let mut cursor = 0usize;
86    while let Some(start) = str_find_from(content, opening, cursor) {
87        let body_start = start + opening.len();
88        let Some(barrier) = str_find_from(content, &closing[..1], body_start) else {
89            break;
90        };
91        cursor = body_start.max(barrier + 1 - opening.len());
92        if content[barrier..].starts_with(closing) {
93            regions.push(TemplateRegion {
94                start,
95                barrier,
96                end: barrier + closing.len(),
97            });
98        }
99    }
100    regions
101}
102
103/// The frame: `opening + [^<first closing char>]* + closing`, anchored at the
104/// region start and truncated at the region end.
105fn template_frame(
106    content: &str,
107    opening: &str,
108    closing: &str,
109    start: usize,
110    end: usize,
111) -> Option<Candidate> {
112    if !content[start..].starts_with(opening) {
113        return None;
114    }
115    let body_start = start + opening.len();
116    let first = closing.chars().next()?;
117    let body_end = walk_forward_while(content, body_start, |c| c != first);
118    if !content[body_end..].starts_with(closing) {
119        return None;
120    }
121    let frame_end = body_end + closing.len();
122    if frame_end > end {
123        return None;
124    }
125    Some(Candidate::new(start, frame_end))
126}
127
128fn search_between(re: &PyRegex, haystack: &str, start: usize, end: usize) -> bool {
129    if start >= end || end > haystack.len() {
130        return false;
131    }
132    re.re().is_match(&haystack[start..end])
133}
134
135/// Arithmetic-branch hit with the `(?<!\d)` guard enforced structurally.
136#[must_use]
137fn arithmetic_in_window(content: &str, arithmetic: &PyRegex, start: usize, end: usize) -> bool {
138    if start >= end || end > content.len() {
139        return false;
140    }
141    let window = &content[start..end];
142    for m in arithmetic.re().find_iter(window) {
143        // lookbehind sees the full string, not just the window
144        let abs_start = start + m.start();
145        let digit_before = content[..abs_start]
146            .chars()
147            .next_back()
148            .is_some_and(|c| c.is_ascii_digit());
149        if !digit_before {
150            return true;
151        }
152    }
153    false
154}
155
156fn date_restart(
157    content: &str,
158    opening: &str,
159    start: usize,
160    barrier: usize,
161    dates: &PyRegex,
162) -> Option<usize> {
163    let from = (start + 2).min(content.len());
164    let to = barrier.min(content.len());
165    if from >= to {
166        return Some(start);
167    }
168    let last_date = dates
169        .re()
170        .find_iter(&content[from..to])
171        .last()
172        .map(|m| m.start() + from);
173    last_date.map_or(Some(start), |pos| {
174        // no later opening before the barrier: the reference finds nothing and
175        // skips the region entirely
176        str_find_from(content, opening, pos + 1).filter(|i| *i < barrier)
177    })
178}
179
180/// `_template_keyword_matches`: an indicator keyword must appear strictly
181/// inside the region (excluding the first char after the opening delimiter).
182#[must_use]
183pub fn template_keyword_matches(
184    content: &str,
185    kind: &TemplateKind,
186    ignore_case: bool,
187) -> Vec<Candidate> {
188    let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
189        return Vec::new();
190    };
191    let mut matches = Vec::new();
192    for region in template_regions(content, kind.opening, kind.closing) {
193        let from = region.start + kind.opening.len() + 1;
194        if search_between(&indicator, content, from, region.barrier)
195            && let Some(frame) = template_frame(
196                content,
197                kind.opening,
198                kind.closing,
199                region.start,
200                region.end,
201            )
202        {
203            matches.push(frame);
204        }
205    }
206    matches
207}
208
209/// `_template_expression_matches`: indicator inside the region, with the
210/// date-dominance restart for `{{`/`#{` forms.
211#[must_use]
212pub fn template_expression_matches(
213    content: &str,
214    kind: &TemplateKind,
215    ignore_case: bool,
216) -> Vec<Candidate> {
217    let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
218        return Vec::new();
219    };
220    let arithmetic = PyRegex::compile(kind.arithmetic, ignore_case).ok();
221    let dates = PyRegex::compile(DATE_SOURCE, ignore_case).ok();
222    let has_dates = matches!(kind.opening, "{{" | "#{");
223    let mut matches = Vec::new();
224    let mut last_end = 0usize;
225    for region in template_regions(content, kind.opening, kind.closing) {
226        if region.start < last_end {
227            continue;
228        }
229        let mut start = region.start;
230        if has_dates && let Some(dates) = &dates {
231            let Some(restarted) = date_restart(content, kind.opening, start, region.barrier, dates)
232            else {
233                continue;
234            };
235            start = restarted;
236        }
237        let hit = search_between(
238            &indicator,
239            content,
240            start + kind.opening.len(),
241            region.barrier,
242        ) || arithmetic.as_ref().is_some_and(|a| {
243            arithmetic_in_window(content, a, start + kind.opening.len(), region.barrier)
244        });
245        if hit
246            && let Some(frame) =
247                template_frame(content, kind.opening, kind.closing, start, region.end)
248        {
249            matches.push(frame);
250            last_end = region.end;
251        }
252    }
253    matches
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn curly_keyword_requires_indicator_inside() {
262        // the reference search window excludes the first body char, so a
263        // keyword flush against the opening delimiter does not fire
264        assert!(template_keyword_matches("{{system('id')}}", &KIND_CURLY_KEYWORD, true).is_empty());
265        let ms = template_keyword_matches("{{ system }}", &KIND_CURLY_KEYWORD, true);
266        assert_eq!(ms.len(), 1);
267        assert!(template_keyword_matches("{{name}}", &KIND_CURLY_KEYWORD, true).is_empty());
268    }
269
270    #[test]
271    fn curly_call_date_suppression() {
272        let stale = "{{ 2024-01-02 }}";
273        assert!(template_expression_matches(stale, &KIND_CURLY_CALL, true).is_empty());
274        let call = "{{ render() }}";
275        assert_eq!(
276            template_expression_matches(call, &KIND_CURLY_CALL, true).len(),
277            1
278        );
279    }
280
281    #[test]
282    fn hash_brace_call() {
283        let ms = template_expression_matches("#{7*7}", &KIND_HASH, true);
284        assert_eq!(ms.len(), 1);
285    }
286
287    #[test]
288    fn dollar_brace_call() {
289        let ms = template_expression_matches("${eval(x)}", &KIND_DOLLAR, true);
290        assert_eq!(ms.len(), 1);
291        assert_eq!(ms[0].text("${eval(x)}"), "${eval(x)}");
292    }
293
294    #[test]
295    fn arithmetic_guard_blocks_digit_preceded_start() {
296        // "3*4" is preceded by a space: the (?<!\d) guard passes
297        assert_eq!(
298            template_expression_matches("{{12 3*4}}", &KIND_CURLY_CALL, true).len(),
299            1
300        );
301        assert_eq!(
302            template_expression_matches("{{7*6}}", &KIND_CURLY_CALL, true).len(),
303            1
304        );
305        // "a123*4" matches at the leading digit ("a" precedes it)
306        assert_eq!(
307            template_expression_matches("{{a123*4}}", &KIND_CURLY_CALL, true).len(),
308            1
309        );
310        // "12 3" cannot start mid-number: the only candidate start "3*" is
311        // fine, but "2 3*4"-style digit-preceded starts never occur here
312        assert_eq!(
313            template_expression_matches("{{x1 3*4}}", &KIND_CURLY_CALL, true).len(),
314            1
315        );
316    }
317}