Skip to main content

sqry_core/config/
recursion.rs

1//! Recursion limits configuration for sqry.
2//!
3//! This module enforces recursion depth limits to prevent resource exhaustion
4//! and stack overflow attacks from deeply nested code structures.
5//!
6//! # Security Model
7//!
8//! Defense-in-depth approach with multiple layers:
9//!
10//! 1. **Default limits**: Conservative defaults (100 file ops, 1000 expr fuel)
11//! 2. **Configurable limits**: Users can adjust based on their needs
12//! 3. **Hard caps**: Absolute maximums that cannot be bypassed (200 file ops, 10,000 expr fuel)
13//! 4. **Validation**: All values validated against hard caps to prevent config injection
14//!
15//! # Attack Vectors Mitigated
16//!
17//! - **Stack overflow**: Deep recursion exhausting call stack
18//! - **Resource exhaustion**: Unbounded recursion consuming CPU/memory
19//! - **Config injection**: Malicious config files setting extreme limits
20//! - **AST bombs**: Pathological inputs with extreme nesting depth
21
22use anyhow::{Result, bail};
23use serde::{Deserialize, Serialize};
24use std::env;
25
26/// Recursion limits configuration
27///
28/// Controls maximum depth for recursive operations to prevent stack overflow
29/// from pathological inputs (deeply nested AST structures, complex expressions).
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RecursionLimits {
32    /// Maximum depth for file operation recursion (AST traversal, directory walking)
33    ///
34    /// Controls how deep the AST traversal can go when processing source files.
35    /// This prevents stack overflow from pathological code like:
36    /// ```text
37    /// fn f0() { fn f1() { fn f2() { ... fn f1000() {} ... } } }
38    /// ```
39    ///
40    /// # Validation
41    /// - Minimum: 50 (must handle moderately nested code)
42    /// - Maximum: 150 (recommended for deeply nested code)
43    /// - Hard cap: 200 (absolute maximum, NON-NEGOTIABLE security constraint)
44    ///
45    /// # Environment Override
46    /// Can be overridden with `SQRY_RECURSION_FILE_OPS_DEPTH` environment variable.
47    pub file_ops_depth: usize,
48
49    /// Maximum depth for expression evaluation (query expressions, AST patterns)
50    ///
51    /// Controls how deep expression trees can be when evaluating queries.
52    /// This prevents stack overflow from complex nested boolean expressions:
53    /// ```text
54    /// (((((a AND b) OR c) AND d) OR e) AND ...)
55    /// ```
56    ///
57    /// # Validation
58    /// - Minimum: 10 (must handle basic expressions)
59    /// - Maximum: 100 (recommended)
60    /// - Hard cap: 200 (absolute maximum, security constraint)
61    ///
62    /// # Environment Override
63    /// Can be overridden with `SQRY_RECURSION_EXPR_DEPTH` environment variable.
64    pub expr_depth: usize,
65
66    /// Fuel limit for expression evaluation (operation counter)
67    ///
68    /// Alternative to depth limiting using an operation counter ("fuel").
69    /// Each recursive call consumes fuel, preventing both deep and wide
70    /// recursion patterns.
71    ///
72    /// # Validation
73    /// - Minimum: 100 (must handle basic queries)
74    /// - Maximum: 5000 (recommended)
75    /// - Hard cap: 10,000 (absolute maximum, NON-NEGOTIABLE security constraint)
76    ///
77    /// # Environment Override
78    /// Can be overridden with `SQRY_RECURSION_EXPR_FUEL` environment variable.
79    pub expr_fuel: usize,
80}
81
82impl Default for RecursionLimits {
83    fn default() -> Self {
84        Self {
85            file_ops_depth: 100,
86            expr_depth: 100,
87            expr_fuel: 1000,
88        }
89    }
90}
91
92impl RecursionLimits {
93    /// Minimum allowed file ops depth
94    pub const MIN_FILE_OPS_DEPTH: usize = 50;
95
96    /// Maximum recommended file ops depth
97    pub const MAX_FILE_OPS_DEPTH: usize = 150;
98
99    /// Absolute hard cap for file ops depth (NON-NEGOTIABLE)
100    ///
101    /// Set to 200 (2x default) to handle legitimate deep directory structures
102    /// while preventing resource exhaustion. This limit has been security-reviewed
103    /// and balances usability with protection against stack overflow attacks.
104    pub const ABSOLUTE_MAX_FILE_OPS_DEPTH: usize = 200;
105
106    /// Minimum allowed expression depth
107    pub const MIN_EXPR_DEPTH: usize = 10;
108
109    /// Maximum recommended expression depth
110    pub const MAX_EXPR_DEPTH: usize = 100;
111
112    /// Absolute hard cap for expression depth
113    pub const ABSOLUTE_MAX_EXPR_DEPTH: usize = 200;
114
115    /// Minimum allowed expression fuel
116    pub const MIN_EXPR_FUEL: usize = 100;
117
118    /// Maximum recommended expression fuel
119    pub const MAX_EXPR_FUEL: usize = 5_000;
120
121    /// Absolute hard cap for expression fuel (NON-NEGOTIABLE)
122    ///
123    /// Set to 10,000 to prevent expression bombs (deeply nested operators) from
124    /// causing stack overflows during query validation and normalization.
125    /// This limit has been security-reviewed and prevents resource exhaustion attacks.
126    pub const ABSOLUTE_MAX_EXPR_FUEL: usize = 10_000;
127
128    /// Create a new recursion limits configuration with custom values
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the provided values violate safety constraints (e.g., values are
133    /// below minimum thresholds or exceed maximum limits).
134    pub fn new(file_ops_depth: usize, expr_depth: usize, expr_fuel: usize) -> Result<Self> {
135        let config = Self {
136            file_ops_depth,
137            expr_depth,
138            expr_fuel,
139        };
140        config.validate()?;
141        Ok(config)
142    }
143
144    /// Load configuration with environment variable overrides
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if environment variables contain invalid values or if the resulting
149    /// configuration violates safety constraints.
150    pub fn load_or_default() -> Result<Self> {
151        let mut config = Self::default();
152
153        // Apply environment variable overrides if present
154        if let Ok(file_ops_str) = env::var("SQRY_RECURSION_FILE_OPS_DEPTH") {
155            config.file_ops_depth =
156                Self::parse_env_var(&file_ops_str, "SQRY_RECURSION_FILE_OPS_DEPTH")?;
157        }
158
159        if let Ok(expr_depth_str) = env::var("SQRY_RECURSION_EXPR_DEPTH") {
160            config.expr_depth = Self::parse_env_var(&expr_depth_str, "SQRY_RECURSION_EXPR_DEPTH")?;
161        }
162
163        if let Ok(expr_fuel_str) = env::var("SQRY_RECURSION_EXPR_FUEL") {
164            config.expr_fuel = Self::parse_env_var(&expr_fuel_str, "SQRY_RECURSION_EXPR_FUEL")?;
165        }
166
167        config.validate()?;
168        Ok(config)
169    }
170
171    /// Get effective file ops depth with validation
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the configured value is 0 (unlimited not allowed), below the minimum
176    /// threshold, or exceeds the absolute maximum limit.
177    pub fn effective_file_ops_depth(&self) -> Result<usize> {
178        if self.file_ops_depth == 0 {
179            bail!("recursion.file_ops_depth cannot be 0 (unlimited not allowed for safety)");
180        }
181
182        if self.file_ops_depth < Self::MIN_FILE_OPS_DEPTH {
183            bail!(
184                "recursion.file_ops_depth {} is below minimum {}",
185                self.file_ops_depth,
186                Self::MIN_FILE_OPS_DEPTH
187            );
188        }
189
190        if self.file_ops_depth > Self::MAX_FILE_OPS_DEPTH {
191            tracing::warn!(
192                "recursion.file_ops_depth {} exceeds recommended maximum {}",
193                self.file_ops_depth,
194                Self::MAX_FILE_OPS_DEPTH
195            );
196        }
197
198        if self.file_ops_depth > Self::ABSOLUTE_MAX_FILE_OPS_DEPTH {
199            bail!(
200                "recursion.file_ops_depth {} exceeds absolute hard cap {}",
201                self.file_ops_depth,
202                Self::ABSOLUTE_MAX_FILE_OPS_DEPTH
203            );
204        }
205
206        Ok(self.file_ops_depth)
207    }
208
209    /// Get effective expression depth with validation
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the configured value is 0 (unlimited not allowed), below the minimum
214    /// threshold, or exceeds the absolute maximum limit.
215    pub fn effective_expr_depth(&self) -> Result<usize> {
216        if self.expr_depth == 0 {
217            bail!("recursion.expr_depth cannot be 0 (unlimited not allowed for safety)");
218        }
219
220        if self.expr_depth < Self::MIN_EXPR_DEPTH {
221            bail!(
222                "recursion.expr_depth {} is below minimum {}",
223                self.expr_depth,
224                Self::MIN_EXPR_DEPTH
225            );
226        }
227
228        if self.expr_depth > Self::MAX_EXPR_DEPTH {
229            tracing::warn!(
230                "recursion.expr_depth {} exceeds recommended maximum {}",
231                self.expr_depth,
232                Self::MAX_EXPR_DEPTH
233            );
234        }
235
236        if self.expr_depth > Self::ABSOLUTE_MAX_EXPR_DEPTH {
237            bail!(
238                "recursion.expr_depth {} exceeds absolute hard cap {}",
239                self.expr_depth,
240                Self::ABSOLUTE_MAX_EXPR_DEPTH
241            );
242        }
243
244        Ok(self.expr_depth)
245    }
246
247    /// Get effective expression fuel with validation
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the configured value is 0 (unlimited not allowed), below the minimum
252    /// threshold, or exceeds the absolute maximum limit.
253    pub fn effective_expr_fuel(&self) -> Result<usize> {
254        if self.expr_fuel == 0 {
255            bail!("recursion.expr_fuel cannot be 0 (unlimited not allowed for safety)");
256        }
257
258        if self.expr_fuel < Self::MIN_EXPR_FUEL {
259            bail!(
260                "recursion.expr_fuel {} is below minimum {}",
261                self.expr_fuel,
262                Self::MIN_EXPR_FUEL
263            );
264        }
265
266        if self.expr_fuel > Self::MAX_EXPR_FUEL {
267            tracing::warn!(
268                "recursion.expr_fuel {} exceeds recommended maximum {}",
269                self.expr_fuel,
270                Self::MAX_EXPR_FUEL
271            );
272        }
273
274        if self.expr_fuel > Self::ABSOLUTE_MAX_EXPR_FUEL {
275            bail!(
276                "recursion.expr_fuel {} exceeds absolute hard cap {}",
277                self.expr_fuel,
278                Self::ABSOLUTE_MAX_EXPR_FUEL
279            );
280        }
281
282        Ok(self.expr_fuel)
283    }
284
285    /// Validate the configuration
286    fn validate(&self) -> Result<()> {
287        // Validation happens in effective_* methods
288        self.effective_file_ops_depth()?;
289        self.effective_expr_depth()?;
290        self.effective_expr_fuel()?;
291        Ok(())
292    }
293
294    /// Parse environment variable with strict error handling
295    fn parse_env_var(value: &str, var_name: &str) -> Result<usize> {
296        match value.parse::<usize>() {
297            Ok(parsed) => Ok(parsed),
298            Err(_) => bail!("Invalid value for {var_name}: '{value}'. Expected usize"),
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_default_config() {
309        let config = RecursionLimits::default();
310        assert_eq!(config.file_ops_depth, 100);
311        assert_eq!(config.expr_depth, 100);
312        assert_eq!(config.expr_fuel, 1000);
313        assert!(config.effective_file_ops_depth().is_ok());
314        assert!(config.effective_expr_depth().is_ok());
315        assert!(config.effective_expr_fuel().is_ok());
316    }
317
318    #[test]
319    fn test_new_with_valid_values() {
320        let config = RecursionLimits::new(200, 50, 5000).unwrap();
321        assert_eq!(config.effective_file_ops_depth().unwrap(), 200);
322        assert_eq!(config.effective_expr_depth().unwrap(), 50);
323        assert_eq!(config.effective_expr_fuel().unwrap(), 5000);
324    }
325
326    #[test]
327    fn test_file_ops_depth_zero_fails() {
328        let result = RecursionLimits::new(0, 100, 1000);
329        assert!(result.is_err());
330        assert!(result.unwrap_err().to_string().contains("cannot be 0"));
331    }
332
333    #[test]
334    fn test_expr_depth_zero_fails() {
335        let result = RecursionLimits::new(100, 0, 1000);
336        assert!(result.is_err());
337        assert!(result.unwrap_err().to_string().contains("cannot be 0"));
338    }
339
340    #[test]
341    fn test_expr_fuel_zero_fails() {
342        let result = RecursionLimits::new(100, 100, 0);
343        assert!(result.is_err());
344        assert!(result.unwrap_err().to_string().contains("cannot be 0"));
345    }
346
347    #[test]
348    fn test_file_ops_depth_below_minimum_fails() {
349        let result = RecursionLimits::new(25, 100, 1000);
350        assert!(result.is_err());
351        assert!(result.unwrap_err().to_string().contains("below minimum 50"));
352    }
353
354    #[test]
355    fn test_expr_depth_below_minimum_fails() {
356        let result = RecursionLimits::new(100, 5, 1000);
357        assert!(result.is_err());
358        assert!(result.unwrap_err().to_string().contains("below minimum 10"));
359    }
360
361    #[test]
362    fn test_expr_fuel_below_minimum_fails() {
363        let result = RecursionLimits::new(100, 100, 50);
364        assert!(result.is_err());
365        assert!(
366            result
367                .unwrap_err()
368                .to_string()
369                .contains("below minimum 100")
370        );
371    }
372
373    #[test]
374    fn test_file_ops_depth_at_minimum_succeeds() {
375        let config = RecursionLimits::new(50, 100, 1000).unwrap();
376        assert_eq!(config.effective_file_ops_depth().unwrap(), 50);
377    }
378
379    #[test]
380    fn test_expr_depth_at_minimum_succeeds() {
381        let config = RecursionLimits::new(100, 10, 1000).unwrap();
382        assert_eq!(config.effective_expr_depth().unwrap(), 10);
383    }
384
385    #[test]
386    fn test_expr_fuel_at_minimum_succeeds() {
387        let config = RecursionLimits::new(100, 100, 100).unwrap();
388        assert_eq!(config.effective_expr_fuel().unwrap(), 100);
389    }
390
391    #[test]
392    fn test_file_ops_depth_at_hard_cap_succeeds() {
393        let config = RecursionLimits::new(200, 100, 1000).unwrap();
394        assert_eq!(config.effective_file_ops_depth().unwrap(), 200);
395    }
396
397    #[test]
398    fn test_expr_depth_at_hard_cap_succeeds() {
399        let config = RecursionLimits::new(100, 200, 1000).unwrap();
400        assert_eq!(config.effective_expr_depth().unwrap(), 200);
401    }
402
403    #[test]
404    fn test_expr_fuel_at_hard_cap_succeeds() {
405        let config = RecursionLimits::new(100, 100, 10_000).unwrap();
406        assert_eq!(config.effective_expr_fuel().unwrap(), 10_000);
407    }
408
409    #[test]
410    fn test_file_ops_depth_above_hard_cap_fails() {
411        let result = RecursionLimits::new(201, 100, 1000);
412        assert!(result.is_err());
413        assert!(
414            result
415                .unwrap_err()
416                .to_string()
417                .contains("exceeds absolute hard cap")
418        );
419    }
420
421    #[test]
422    fn test_expr_depth_above_hard_cap_fails() {
423        let result = RecursionLimits::new(100, 201, 1000);
424        assert!(result.is_err());
425        assert!(
426            result
427                .unwrap_err()
428                .to_string()
429                .contains("exceeds absolute hard cap")
430        );
431    }
432
433    #[test]
434    fn test_expr_fuel_above_hard_cap_fails() {
435        let result = RecursionLimits::new(100, 100, 10_001);
436        assert!(result.is_err());
437        assert!(
438            result
439                .unwrap_err()
440                .to_string()
441                .contains("exceeds absolute hard cap")
442        );
443    }
444
445    #[test]
446    fn test_parse_env_var_valid() {
447        let result = RecursionLimits::parse_env_var("150", "TEST_VAR");
448        assert_eq!(result.unwrap(), 150);
449    }
450
451    #[test]
452    fn test_parse_env_var_invalid() {
453        let result = RecursionLimits::parse_env_var("abc", "TEST_VAR");
454        assert!(result.is_err());
455        assert!(
456            result
457                .unwrap_err()
458                .to_string()
459                .contains("Invalid value for TEST_VAR")
460        );
461    }
462
463    #[test]
464    fn test_parse_env_var_negative() {
465        let result = RecursionLimits::parse_env_var("-100", "TEST_VAR");
466        assert!(result.is_err());
467        assert!(result.unwrap_err().to_string().contains("Invalid value"));
468    }
469}