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    #[cfg(not(coverage))] // unreachable: every kind indicator is a statically
189    // valid literal
190    let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
191        return Vec::new();
192    };
193    #[cfg(coverage)]
194    let indicator = PyRegex::compile(kind.indicator, ignore_case).expect("statically valid");
195    let mut matches = Vec::new();
196    for region in template_regions(content, kind.opening, kind.closing) {
197        let from = region.start + kind.opening.len() + 1;
198        if search_between(&indicator, content, from, region.barrier)
199            && let Some(frame) = template_frame(
200                content,
201                kind.opening,
202                kind.closing,
203                region.start,
204                region.end,
205            )
206        {
207            matches.push(frame);
208        }
209    }
210    matches
211}
212
213/// `_template_expression_matches`: indicator inside the region, with the
214/// date-dominance restart for `{{`/`#{` forms.
215#[must_use]
216pub fn template_expression_matches(
217    content: &str,
218    kind: &TemplateKind,
219    ignore_case: bool,
220) -> Vec<Candidate> {
221    #[cfg(not(coverage))] // unreachable: every kind indicator is a statically
222    // valid literal
223    let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
224        return Vec::new();
225    };
226    #[cfg(coverage)]
227    let indicator = PyRegex::compile(kind.indicator, ignore_case).expect("statically valid");
228    let arithmetic = PyRegex::compile(kind.arithmetic, ignore_case).ok();
229    let dates = PyRegex::compile(DATE_SOURCE, ignore_case).ok();
230    let has_dates = matches!(kind.opening, "{{" | "#{");
231    let mut matches = Vec::new();
232    let mut last_end = 0usize;
233    for region in template_regions(content, kind.opening, kind.closing) {
234        if region.start < last_end {
235            continue;
236        }
237        let mut start = region.start;
238        if has_dates && let Some(dates) = &dates {
239            let Some(restarted) = date_restart(content, kind.opening, start, region.barrier, dates)
240            else {
241                continue;
242            };
243            start = restarted;
244        }
245        let hit = search_between(
246            &indicator,
247            content,
248            start + kind.opening.len(),
249            region.barrier,
250        ) || arithmetic.as_ref().is_some_and(|a| {
251            arithmetic_in_window(content, a, start + kind.opening.len(), region.barrier)
252        });
253        if hit
254            && let Some(frame) =
255                template_frame(content, kind.opening, kind.closing, start, region.end)
256        {
257            matches.push(frame);
258            last_end = region.end;
259        }
260    }
261    matches
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn curly_keyword_requires_indicator_inside() {
270        // the reference search window excludes the first body char, so a
271        // keyword flush against the opening delimiter does not fire
272        assert!(template_keyword_matches("{{system('id')}}", &KIND_CURLY_KEYWORD, true).is_empty());
273        let ms = template_keyword_matches("{{ system }}", &KIND_CURLY_KEYWORD, true);
274        assert_eq!(ms.len(), 1);
275        assert!(template_keyword_matches("{{name}}", &KIND_CURLY_KEYWORD, true).is_empty());
276    }
277
278    #[test]
279    fn curly_call_date_suppression() {
280        let stale = "{{ 2024-01-02 }}";
281        assert!(template_expression_matches(stale, &KIND_CURLY_CALL, true).is_empty());
282        let call = "{{ render() }}";
283        assert_eq!(
284            template_expression_matches(call, &KIND_CURLY_CALL, true).len(),
285            1
286        );
287    }
288
289    #[test]
290    fn hash_brace_call() {
291        let ms = template_expression_matches("#{7*7}", &KIND_HASH, true);
292        assert_eq!(ms.len(), 1);
293    }
294
295    #[test]
296    fn dollar_brace_call() {
297        let ms = template_expression_matches("${eval(x)}", &KIND_DOLLAR, true);
298        assert_eq!(ms.len(), 1);
299        assert_eq!(ms[0].text("${eval(x)}"), "${eval(x)}");
300    }
301
302    #[test]
303    fn arithmetic_guard_blocks_digit_preceded_start() {
304        // "3*4" is preceded by a space: the (?<!\d) guard passes
305        assert_eq!(
306            template_expression_matches("{{12 3*4}}", &KIND_CURLY_CALL, true).len(),
307            1
308        );
309        assert_eq!(
310            template_expression_matches("{{7*6}}", &KIND_CURLY_CALL, true).len(),
311            1
312        );
313        // "a123*4" matches at the leading digit ("a" precedes it)
314        assert_eq!(
315            template_expression_matches("{{a123*4}}", &KIND_CURLY_CALL, true).len(),
316            1
317        );
318        // "12 3" cannot start mid-number: the only candidate start "3*" is
319        // fine, but "2 3*4"-style digit-preceded starts never occur here
320        assert_eq!(
321            template_expression_matches("{{x1 3*4}}", &KIND_CURLY_CALL, true).len(),
322            1
323        );
324    }
325}
326
327#[cfg(test)]
328mod coverage_tests {
329    use super::*;
330
331    #[test]
332    fn malformed_frames_and_missing_closers_are_rejected() {
333        // two adjacent regions both match
334        assert_eq!(
335            template_keyword_matches("{{ system }}{{ system }}", &KIND_CURLY_KEYWORD, true).len(),
336            2
337        );
338        // a keyword inside the first char after the opening is not "inside"
339        assert!(template_keyword_matches("{{system}}", &KIND_CURLY_KEYWORD, true).is_empty());
340        // an unterminated frame: no region, nothing matches
341        assert!(template_keyword_matches("{{ system ", &KIND_CURLY_KEYWORD, true).is_empty());
342    }
343}
344
345#[cfg(test)]
346mod gap_tests {
347    use super::*;
348
349    #[test]
350    fn digit_preceded_arithmetic_candidates_are_rejected() {
351        // the quote forces the arithmetic match to start after the leading
352        // digit, which is exactly the start the (?<!\d) guard rejects
353        assert!(template_expression_matches("#{1\"2*3}", &KIND_HASH, true).is_empty());
354        // the later region in the same content still fires
355        assert_eq!(
356            template_expression_matches("#{1\"2*3}#{eval(x)}", &KIND_HASH, true).len(),
357            1
358        );
359    }
360
361    #[test]
362    fn a_region_body_shorter_than_the_restart_lookback_restarts_in_place() {
363        // "#{}" has no body to scan for dates: the restart returns the region
364        // start unchanged and the empty region never hits
365        assert_eq!(
366            template_expression_matches("#{}#{eval(x)}", &KIND_HASH, true).len(),
367            1
368        );
369    }
370
371    #[test]
372    fn an_overlapping_second_region_is_skipped_after_a_hit() {
373        // the inner `<%` region starts inside the first region's accepted
374        // span, so the restart cursor yields it and it is dropped
375        assert_eq!(
376            template_expression_matches("<%eval<%>%>", &KIND_ASP, true).len(),
377            1
378        );
379    }
380}
381
382#[cfg(test)]
383mod unit_twins {
384    use super::*;
385
386    #[test]
387    fn template_frame_rejects_misaligned_and_unterminated_windows() {
388        // the start does not sit on the opening delimiter
389        assert_eq!(template_frame("{{x}}", "{{", "}}", 1, 5), None);
390        // the body never reaches the closing delimiter
391        assert_eq!(template_frame("{{x", "{{", "}}", 0, 3), None);
392        // the frame extends past the candidate end
393        assert_eq!(template_frame("{{x}} tail", "{{", "}}", 0, 4), None);
394        // a well-formed frame inside the window renders
395        let frame = template_frame("{{x}} tail", "{{", "}}", 0, 10).expect("frame");
396        assert_eq!((frame.start, frame.end), (0, 5));
397        // an empty closing delimiter has no leading char to scan for
398        assert_eq!(template_frame("{{x}}", "{{", "", 0, 5), None);
399    }
400
401    #[test]
402    fn date_restart_finds_the_next_opening_inside_the_barrier() {
403        let dates = PyRegex::compile(DATE_SOURCE, false).expect("statically valid");
404        // a date dominates the region: the restart looks for a later opening
405        // before the barrier and finds one
406        let content = "{{a}} 2024-01-01 {{b}}";
407        let barrier = content.len();
408        assert_eq!(date_restart(content, "{{", 0, barrier, &dates), Some(17));
409        // the later opening sits exactly at the barrier: filtered out
410        assert_eq!(date_restart(content, "{{", 0, 17, &dates), None);
411    }
412}