Skip to main content

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}