oxigdal_algorithms/expr_depth.rs
1//! Nesting-depth limits for the raster-algebra expression front-ends
2//!
3//! Both expression front-ends in this crate — the Pest-based [`crate::dsl`]
4//! parser and the hand-written [`mod@crate::raster`] calculator parser — are
5//! recursive descent. Without a bound, a deeply nested expression such as
6//! `((((((… x …))))))` drives one native stack frame chain per nesting level
7//! and aborts the process with a stack overflow. Because a stack overflow is a
8//! `SIGABRT`/`SIGSEGV` rather than a catchable panic, it cannot be recovered
9//! from, so untrusted expression text would be a denial-of-service vector.
10//!
11//! The mitigation is a hard, documented depth limit — [`MAX_EXPRESSION_DEPTH`]
12//! — enforced *before* any recursion is entered, so over-deep input yields
13//! [`AlgorithmError::NestingTooDeep`]
14//! instead of aborting.
15//!
16//! # Choosing the limit
17//!
18//! The limit is sized against the most stack-hungry front-end. Measured on
19//! aarch64-apple-darwin with the workspace release-ish test profile, by
20//! bisecting the nesting depth at which a thread of a given stack size aborts:
21//!
22//! | Front-end / input shape | stack per nesting level |
23//! |-----------------------------------------------|-------------------------|
24//! | `dsl::parse_expression`, `(x + 1)` nesting | ~12.5 KiB |
25//! | `dsl::parse_expression`, `if … else if …` | ~12.5 KiB |
26//! | `dsl::parse_expression`, `---x` unary run | ~0.8 KiB |
27//! | `RasterCalculator::evaluate`, `(…)` nesting | ~2.4 KiB |
28//! | `RasterCalculator::evaluate`, `---B1` run | ~0.4 KiB |
29//!
30//! The DSL figure is dominated by Pest's generated parser: the expression
31//! grammar threads ten rules (`expression` → `logical_or` → … → `primary`)
32//! per nesting level, and each generated rule frame is roughly a kilobyte.
33//!
34//! At the worst-case 12.5 KiB per level, [`MAX_EXPRESSION_DEPTH`] = 64 costs
35//! about 768 KiB of stack — the measured floor below which an expression at
36//! exactly the limit stops fitting. That is 37% of the 2 MiB stack Rust gives
37//! a non-main thread, the smallest stack any realistic caller (a Rayon worker,
38//! a Tokio blocking task, a request handler) will have, leaving ~1.28 MiB for
39//! the caller's own frames. Put the other way: the unguarded DSL parser aborts
40//! at depth 165 on a 2 MiB stack, so the limit sits 2.6x below the cliff.
41//!
42//! For comparison, real raster algebra is shallow: NDVI `(B1 - B2) / (B1 + B2)`
43//! nests 2 deep, and even elaborate multi-index expressions stay under 10. A
44//! limit of 64 is therefore far above any legitimate expression while staying
45//! far below the stack budget.
46
47use crate::error::{AlgorithmError, Result};
48
49/// Maximum nesting depth accepted by the raster-algebra expression parsers.
50///
51/// Expressions nesting more deeply than this are rejected with
52/// [`AlgorithmError::NestingTooDeep`]
53/// rather than being allowed to exhaust the thread stack. See the
54/// [module documentation](self) for how the value was derived.
55pub const MAX_EXPRESSION_DEPTH: usize = 64;
56
57/// Returns an error if `depth` has passed [`MAX_EXPRESSION_DEPTH`].
58///
59/// Called on entry to every recursive parser function so that the recursion is
60/// bounded explicitly rather than by catching an overflow after the fact.
61#[inline]
62pub(crate) fn guard_depth(depth: usize, context: &'static str) -> Result<()> {
63 if depth > MAX_EXPRESSION_DEPTH {
64 return Err(AlgorithmError::NestingTooDeep {
65 context,
66 depth,
67 max: MAX_EXPRESSION_DEPTH,
68 });
69 }
70 Ok(())
71}
72
73/// Conservative upper bound on the recursive-descent depth needed to parse
74/// `input`.
75///
76/// This exists for the Pest-backed [`crate::dsl`] parser. Pest generates the
77/// recursive descent itself, so no depth counter can be threaded through it;
78/// the only way to keep it off the stack cliff is to reject over-deep source
79/// text *before* handing it to the generated parser. The scan is a single
80/// allocation-light pass over the bytes and never under-estimates the depth the
81/// grammar will reach.
82///
83/// Three constructs in `dsl/grammar.pest` drive recursion back into
84/// `expression`, and all three are accounted for here:
85///
86/// * bracketing — `"(" ~ expression ~ ")"`, `function_call`, and `block`;
87/// * `if` / `for` prefixes — `conditional` recurses through its `else` branch,
88/// so `if a then 1 else if b then 1 else 0` nests without any bracket;
89/// * unary sign runs — `unary = (sub_op | add_op) ~ unary` recurses per sign,
90/// so `-----x` nests without any bracket.
91///
92/// Comments and numeric exponents are skipped so that parentheses inside a
93/// comment, or the `-` of `1e-3`, are not miscounted.
94///
95/// The scan short-circuits as soon as the running depth passes
96/// [`MAX_EXPRESSION_DEPTH`], so the return value is exact only while it is
97/// within the limit; beyond that it is the first over-limit depth observed.
98#[cfg(feature = "dsl")]
99pub(crate) fn source_nesting_depth(input: &str) -> usize {
100 let bytes = input.as_bytes();
101 let len = bytes.len();
102
103 // One counter per open bracket group, holding the number of `if`/`for`
104 // prefixes opened in that group since the last `;`. Prefixes are dropped
105 // when their group closes or when a statement ends, which keeps sibling
106 // statements from accumulating depth against each other.
107 let mut groups: alloc_vec::Vec<usize> = alloc_vec::vec![0];
108 let mut prefix_total: usize = 0;
109 let mut unary_run: usize = 0;
110 let mut max_depth: usize = 0;
111 // True where a `+`/`-` would be a unary sign rather than a binary operator.
112 let mut unary_position = true;
113 let mut index = 0usize;
114
115 while index < len {
116 let byte = bytes[index];
117 match byte {
118 b' ' | b'\t' | b'\r' | b'\n' => index += 1,
119
120 // Line comment: `// … \n`
121 b'/' if index + 1 < len && bytes[index + 1] == b'/' => {
122 index += 2;
123 while index < len && bytes[index] != b'\n' {
124 index += 1;
125 }
126 }
127
128 // Block comment: `/* … */`
129 b'/' if index + 1 < len && bytes[index + 1] == b'*' => {
130 index += 2;
131 while index + 1 < len && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
132 index += 1;
133 }
134 index = index.saturating_add(2).min(len);
135 }
136
137 b'(' | b'{' => {
138 groups.push(0);
139 unary_run = 0;
140 unary_position = true;
141 index += 1;
142 max_depth = max_depth.max(groups.len() - 1 + prefix_total);
143 }
144
145 b')' | b'}' => {
146 if groups.len() > 1 {
147 let closed = groups.pop().unwrap_or(0);
148 prefix_total = prefix_total.saturating_sub(closed);
149 }
150 unary_run = 0;
151 unary_position = false;
152 index += 1;
153 }
154
155 // Statement boundary: prefixes opened in this group are complete.
156 b';' => {
157 if let Some(top) = groups.last_mut() {
158 prefix_total = prefix_total.saturating_sub(*top);
159 *top = 0;
160 }
161 unary_run = 0;
162 unary_position = true;
163 index += 1;
164 }
165
166 b'+' | b'-' if unary_position => {
167 unary_run += 1;
168 index += 1;
169 max_depth = max_depth.max(groups.len() - 1 + prefix_total + unary_run);
170 }
171
172 // Identifiers and keywords.
173 b'A'..=b'Z' | b'a'..=b'z' | b'_' => {
174 let start = index;
175 while index < len && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_')
176 {
177 index += 1;
178 }
179 // Identifier bytes are ASCII, so this slice is always on a
180 // character boundary.
181 let word = input.get(start..index).unwrap_or("");
182 unary_run = 0;
183 if word.eq_ignore_ascii_case("if") || word.eq_ignore_ascii_case("for") {
184 if let Some(top) = groups.last_mut() {
185 *top += 1;
186 }
187 prefix_total += 1;
188 unary_position = true;
189 max_depth = max_depth.max(groups.len() - 1 + prefix_total);
190 } else {
191 // Keywords are followed by an operand, so a following sign
192 // is unary; a plain identifier ends an operand, so it is not.
193 unary_position = matches!(
194 word.to_ascii_lowercase().as_str(),
195 "then" | "else" | "in" | "let" | "return" | "and" | "or" | "not"
196 );
197 }
198 }
199
200 // Numeric literal, including an exponent whose sign must not be
201 // mistaken for a unary operator.
202 b'0'..=b'9' => {
203 while index < len && bytes[index].is_ascii_digit() {
204 index += 1;
205 }
206 if index + 1 < len && bytes[index] == b'.' && bytes[index + 1].is_ascii_digit() {
207 index += 1;
208 while index < len && bytes[index].is_ascii_digit() {
209 index += 1;
210 }
211 }
212 if index < len && (bytes[index] | 0x20) == b'e' {
213 let mut lookahead = index + 1;
214 if lookahead < len && (bytes[lookahead] == b'+' || bytes[lookahead] == b'-') {
215 lookahead += 1;
216 }
217 if lookahead < len && bytes[lookahead].is_ascii_digit() {
218 index = lookahead;
219 while index < len && bytes[index].is_ascii_digit() {
220 index += 1;
221 }
222 }
223 }
224 unary_run = 0;
225 unary_position = false;
226 }
227
228 // Every other byte is an operator, a separator, or invalid input
229 // that Pest will reject; all of them put us back in unary position.
230 _ => {
231 unary_run = 0;
232 unary_position = true;
233 index += 1;
234 }
235 }
236
237 if max_depth > MAX_EXPRESSION_DEPTH {
238 return max_depth;
239 }
240 }
241
242 max_depth
243}
244
245/// Rejects `input` when [`source_nesting_depth`] exceeds [`MAX_EXPRESSION_DEPTH`].
246#[cfg(feature = "dsl")]
247pub(crate) fn check_source_nesting_depth(input: &str, context: &'static str) -> Result<()> {
248 let depth = source_nesting_depth(input);
249 if depth > MAX_EXPRESSION_DEPTH {
250 return Err(AlgorithmError::NestingTooDeep {
251 context,
252 depth,
253 max: MAX_EXPRESSION_DEPTH,
254 });
255 }
256 Ok(())
257}
258
259/// `Vec`/`vec!` regardless of whether `std` is in play.
260#[cfg(feature = "dsl")]
261mod alloc_vec {
262 #[cfg(feature = "std")]
263 pub(super) use std::{vec, vec::Vec};
264
265 #[cfg(not(feature = "std"))]
266 pub(super) use alloc::{vec, vec::Vec};
267}
268
269#[cfg(test)]
270#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn guard_depth_accepts_up_to_the_limit() {
276 assert!(guard_depth(0, "test").is_ok());
277 assert!(guard_depth(MAX_EXPRESSION_DEPTH, "test").is_ok());
278 }
279
280 #[test]
281 fn guard_depth_rejects_beyond_the_limit() {
282 let err = guard_depth(MAX_EXPRESSION_DEPTH + 1, "test")
283 .expect_err("depth past the limit must be rejected");
284 assert!(matches!(
285 err,
286 AlgorithmError::NestingTooDeep {
287 context: "test",
288 max: MAX_EXPRESSION_DEPTH,
289 ..
290 }
291 ));
292 // The limit must be discoverable from the message.
293 assert!(err.to_string().contains(&MAX_EXPRESSION_DEPTH.to_string()));
294 }
295
296 #[cfg(feature = "dsl")]
297 #[test]
298 fn scanner_counts_bracket_nesting() {
299 assert_eq!(source_nesting_depth("x"), 0);
300 assert_eq!(source_nesting_depth("(x)"), 1);
301 assert_eq!(source_nesting_depth("((x))"), 2);
302 assert_eq!(source_nesting_depth("(B1 - B2) / (B1 + B2)"), 1);
303 // Function-call parentheses nest just like grouping parentheses.
304 assert_eq!(source_nesting_depth("sqrt(abs(B1))"), 2);
305 // Siblings do not accumulate.
306 assert_eq!(source_nesting_depth("(a) + (b) + (c)"), 1);
307 }
308
309 #[cfg(feature = "dsl")]
310 #[test]
311 fn scanner_counts_if_chains_without_brackets() {
312 assert_eq!(source_nesting_depth("if a then 1 else 0"), 1);
313 assert_eq!(
314 source_nesting_depth("if a then 1 else if b then 1 else 0"),
315 2
316 );
317 // Separate statements reset at the `;` boundary.
318 assert_eq!(
319 source_nesting_depth("if a then 1 else 0; if b then 1 else 0;"),
320 1
321 );
322 }
323
324 #[cfg(feature = "dsl")]
325 #[test]
326 fn scanner_counts_unary_runs_but_not_binary_operators() {
327 assert_eq!(source_nesting_depth("---x"), 3);
328 assert_eq!(source_nesting_depth("2 * -3"), 1);
329 // A long flat sum is depth 0: these minus signs are binary.
330 let flat = (0..200)
331 .map(|i| i.to_string())
332 .collect::<std::vec::Vec<_>>()
333 .join(" - ");
334 assert_eq!(source_nesting_depth(&flat), 0);
335 }
336
337 #[cfg(feature = "dsl")]
338 #[test]
339 fn scanner_ignores_comments_and_exponents() {
340 // Parentheses inside comments must not count.
341 assert_eq!(source_nesting_depth("x // ((((((\n"), 0);
342 assert_eq!(source_nesting_depth("x /* (((((( */ + 1"), 0);
343 // The `-` of an exponent is not a unary operator.
344 assert_eq!(source_nesting_depth("1e-3 + 2.5e+4"), 0);
345 }
346
347 #[cfg(feature = "dsl")]
348 #[test]
349 fn scanner_never_underestimates_deep_input() {
350 let mut expr = std::string::String::from("x");
351 for _ in 0..1000 {
352 expr = std::format!("({expr} + 1)");
353 }
354 assert!(source_nesting_depth(&expr) > MAX_EXPRESSION_DEPTH);
355 assert!(check_source_nesting_depth(&expr, "dsl").is_err());
356 }
357
358 #[cfg(feature = "dsl")]
359 #[test]
360 fn check_accepts_exactly_the_limit() {
361 let mut expr = std::string::String::from("x");
362 for _ in 0..MAX_EXPRESSION_DEPTH {
363 expr = std::format!("({expr})");
364 }
365 assert_eq!(source_nesting_depth(&expr), MAX_EXPRESSION_DEPTH);
366 assert!(check_source_nesting_depth(&expr, "dsl").is_ok());
367 }
368}