guard_core_engine/patterns/
templates.rs1use 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 pub indicator: &'static str,
32 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#[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
103fn 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#[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 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 str_find_from(content, opening, pos + 1).filter(|i| *i < barrier)
177 })
178}
179
180#[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#[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 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 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 assert_eq!(
307 template_expression_matches("{{a123*4}}", &KIND_CURLY_CALL, true).len(),
308 1
309 );
310 assert_eq!(
313 template_expression_matches("{{x1 3*4}}", &KIND_CURLY_CALL, true).len(),
314 1
315 );
316 }
317}