Skip to main content

bashkit/
limits.rs

1//! Resource limits for virtual execution.
2//!
3//! These limits prevent runaway scripts from consuming excessive resources.
4//!
5//! # Security Mitigations
6//!
7//! This module mitigates the following threats (see `specs/006-threat-model.md`):
8//!
9//! - **TM-DOS-001**: Large script input → `max_input_bytes`
10//! - **TM-DOS-002, TM-DOS-004, TM-DOS-019**: Command flooding → `max_commands`
11//! - **TM-DOS-016, TM-DOS-017**: Infinite loops → `max_loop_iterations`
12//! - **TM-DOS-018**: Nested loop multiplication → `max_total_loop_iterations`
13//! - **TM-DOS-020, TM-DOS-021**: Function recursion → `max_function_depth`
14//! - **TM-DOS-022**: Parser recursion → `max_ast_depth`
15//! - **TM-DOS-023**: CPU exhaustion → `timeout`
16//! - **TM-DOS-024**: Parser hang → `parser_timeout`, `max_parser_operations`
17//! - **TM-DOS-027**: Builtin parser recursion → `MAX_AWK_PARSER_DEPTH`, `MAX_JQ_JSON_DEPTH` (in builtins)
18//!
19//! # Fail Points (enabled with `failpoints` feature)
20//!
21//! - `limits::tick_command` - Inject failures in command counting
22//! - `limits::tick_loop` - Inject failures in loop iteration counting
23//! - `limits::push_function` - Inject failures in function depth tracking
24
25use std::time::Duration;
26
27#[cfg(feature = "failpoints")]
28use fail::fail_point;
29
30/// Resource limits for script execution
31#[derive(Debug, Clone)]
32pub struct ExecutionLimits {
33    /// Maximum number of commands that can be executed (fuel model)
34    /// Default: 10,000
35    pub max_commands: usize,
36
37    /// Maximum iterations for a single loop
38    /// Default: 10,000
39    pub max_loop_iterations: usize,
40
41    // THREAT[TM-DOS-018]: Nested loops each reset their per-loop counter,
42    // allowing 10K^depth total iterations. This global cap prevents that.
43    /// Maximum total loop iterations across all loops (nested and sequential).
44    /// Prevents nested loop multiplication attack (TM-DOS-018).
45    /// Default: 1,000,000
46    pub max_total_loop_iterations: usize,
47
48    /// Maximum function call depth (recursion limit)
49    /// Default: 100
50    pub max_function_depth: usize,
51
52    /// Execution timeout
53    /// Default: 30 seconds
54    pub timeout: Duration,
55
56    /// Parser timeout (separate from execution timeout)
57    /// Default: 5 seconds
58    /// This limits how long the parser can spend parsing a script before giving up.
59    /// Protects against parser hang attacks (V3 in threat model).
60    pub parser_timeout: Duration,
61
62    /// Maximum input script size in bytes
63    /// Default: 10MB (10,000,000 bytes)
64    /// Protects against memory exhaustion from large scripts (V1 in threat model).
65    pub max_input_bytes: usize,
66
67    /// Maximum AST nesting depth during parsing
68    /// Default: 100
69    /// Protects against stack overflow from deeply nested scripts (V4 in threat model).
70    pub max_ast_depth: usize,
71
72    /// Maximum parser operations (fuel model for parsing)
73    /// Default: 100,000
74    /// Protects against parser DoS attacks that could otherwise cause CPU exhaustion.
75    pub max_parser_operations: usize,
76}
77
78impl Default for ExecutionLimits {
79    fn default() -> Self {
80        Self {
81            max_commands: 10_000,
82            max_loop_iterations: 10_000,
83            max_total_loop_iterations: 1_000_000,
84            max_function_depth: 100,
85            timeout: Duration::from_secs(30),
86            parser_timeout: Duration::from_secs(5),
87            max_input_bytes: 10_000_000, // 10MB
88            max_ast_depth: 100,
89            max_parser_operations: 100_000,
90        }
91    }
92}
93
94impl ExecutionLimits {
95    /// Create new limits with defaults
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Set maximum command count
101    pub fn max_commands(mut self, count: usize) -> Self {
102        self.max_commands = count;
103        self
104    }
105
106    /// Set maximum loop iterations (per-loop)
107    pub fn max_loop_iterations(mut self, count: usize) -> Self {
108        self.max_loop_iterations = count;
109        self
110    }
111
112    /// Set maximum total loop iterations (across all nested/sequential loops).
113    /// Prevents TM-DOS-018 nested loop multiplication.
114    pub fn max_total_loop_iterations(mut self, count: usize) -> Self {
115        self.max_total_loop_iterations = count;
116        self
117    }
118
119    /// Set maximum function depth
120    pub fn max_function_depth(mut self, depth: usize) -> Self {
121        self.max_function_depth = depth;
122        self
123    }
124
125    /// Set execution timeout
126    pub fn timeout(mut self, timeout: Duration) -> Self {
127        self.timeout = timeout;
128        self
129    }
130
131    /// Set parser timeout
132    pub fn parser_timeout(mut self, timeout: Duration) -> Self {
133        self.parser_timeout = timeout;
134        self
135    }
136
137    /// Set maximum input script size in bytes
138    pub fn max_input_bytes(mut self, bytes: usize) -> Self {
139        self.max_input_bytes = bytes;
140        self
141    }
142
143    /// Set maximum AST nesting depth
144    pub fn max_ast_depth(mut self, depth: usize) -> Self {
145        self.max_ast_depth = depth;
146        self
147    }
148
149    /// Set maximum parser operations
150    pub fn max_parser_operations(mut self, ops: usize) -> Self {
151        self.max_parser_operations = ops;
152        self
153    }
154}
155
156/// Execution counters for tracking resource usage
157#[derive(Debug, Clone, Default)]
158pub struct ExecutionCounters {
159    /// Number of commands executed
160    pub commands: usize,
161
162    /// Current function call depth
163    pub function_depth: usize,
164
165    /// Number of iterations in current loop (reset per-loop)
166    pub loop_iterations: usize,
167
168    // THREAT[TM-DOS-018]: Nested loop multiplication
169    // This counter never resets, tracking total iterations across all loops.
170    /// Total loop iterations across all loops (never reset)
171    pub total_loop_iterations: usize,
172}
173
174impl ExecutionCounters {
175    /// Create new counters
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Reset counters for a new exec() invocation.
181    /// Each exec() is a separate script and gets its own budget.
182    /// This prevents a prior exec() from permanently poisoning the session.
183    pub fn reset_for_execution(&mut self) {
184        self.commands = 0;
185        self.loop_iterations = 0;
186        self.total_loop_iterations = 0;
187        // function_depth should already be 0 between exec() calls,
188        // but reset defensively to avoid stuck state
189        self.function_depth = 0;
190    }
191
192    /// Increment command counter, returns error if limit exceeded
193    pub fn tick_command(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
194        // Fail point: test behavior when counter increment is corrupted
195        #[cfg(feature = "failpoints")]
196        fail_point!("limits::tick_command", |action| {
197            match action.as_deref() {
198                Some("skip_increment") => {
199                    // Simulate counter not incrementing (potential bypass)
200                    return Ok(());
201                }
202                Some("force_overflow") => {
203                    // Simulate counter overflow
204                    self.commands = usize::MAX;
205                    return Err(LimitExceeded::MaxCommands(limits.max_commands));
206                }
207                Some("corrupt_high") => {
208                    // Simulate counter corruption to a high value
209                    self.commands = limits.max_commands + 1;
210                }
211                _ => {}
212            }
213            Ok(())
214        });
215
216        self.commands += 1;
217        if self.commands > limits.max_commands {
218            return Err(LimitExceeded::MaxCommands(limits.max_commands));
219        }
220        Ok(())
221    }
222
223    /// Increment loop iteration counter, returns error if limit exceeded
224    pub fn tick_loop(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
225        // Fail point: test behavior when loop counter is corrupted
226        #[cfg(feature = "failpoints")]
227        fail_point!("limits::tick_loop", |action| {
228            match action.as_deref() {
229                Some("skip_check") => {
230                    // Simulate limit check being bypassed
231                    self.loop_iterations += 1;
232                    return Ok(());
233                }
234                Some("reset_counter") => {
235                    // Simulate counter being reset (infinite loop potential)
236                    self.loop_iterations = 0;
237                    return Ok(());
238                }
239                _ => {}
240            }
241            Ok(())
242        });
243
244        self.loop_iterations += 1;
245        self.total_loop_iterations += 1;
246        if self.loop_iterations > limits.max_loop_iterations {
247            return Err(LimitExceeded::MaxLoopIterations(limits.max_loop_iterations));
248        }
249        // THREAT[TM-DOS-018]: Check global cap to prevent nested loop multiplication
250        if self.total_loop_iterations > limits.max_total_loop_iterations {
251            return Err(LimitExceeded::MaxTotalLoopIterations(
252                limits.max_total_loop_iterations,
253            ));
254        }
255        Ok(())
256    }
257
258    /// Reset loop iteration counter (called when entering a new loop)
259    pub fn reset_loop(&mut self) {
260        self.loop_iterations = 0;
261    }
262
263    /// Push function call, returns error if depth exceeded
264    pub fn push_function(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
265        // Fail point: test behavior when function depth tracking fails
266        #[cfg(feature = "failpoints")]
267        fail_point!("limits::push_function", |action| {
268            match action.as_deref() {
269                Some("skip_check") => {
270                    // Simulate depth check being bypassed (stack overflow potential)
271                    self.function_depth += 1;
272                    return Ok(());
273                }
274                Some("corrupt_depth") => {
275                    // Simulate depth counter corruption
276                    self.function_depth = 0;
277                    return Ok(());
278                }
279                _ => {}
280            }
281            Ok(())
282        });
283
284        // Check before incrementing so we don't leave invalid state on failure
285        if self.function_depth >= limits.max_function_depth {
286            return Err(LimitExceeded::MaxFunctionDepth(limits.max_function_depth));
287        }
288        self.function_depth += 1;
289        Ok(())
290    }
291
292    /// Pop function call
293    pub fn pop_function(&mut self) {
294        if self.function_depth > 0 {
295            self.function_depth -= 1;
296        }
297    }
298}
299
300/// Error returned when a resource limit is exceeded
301#[derive(Debug, Clone, thiserror::Error)]
302pub enum LimitExceeded {
303    #[error("maximum command count exceeded ({0})")]
304    MaxCommands(usize),
305
306    #[error("maximum loop iterations exceeded ({0})")]
307    MaxLoopIterations(usize),
308
309    #[error("maximum total loop iterations exceeded ({0})")]
310    MaxTotalLoopIterations(usize),
311
312    #[error("maximum function depth exceeded ({0})")]
313    MaxFunctionDepth(usize),
314
315    #[error("execution timeout ({0:?})")]
316    Timeout(Duration),
317
318    #[error("parser timeout ({0:?})")]
319    ParserTimeout(Duration),
320
321    #[error("input too large ({0} bytes, max {1} bytes)")]
322    InputTooLarge(usize, usize),
323
324    #[error("AST nesting too deep ({0} levels, max {1})")]
325    AstTooDeep(usize, usize),
326
327    #[error("parser fuel exhausted ({0} operations, max {1})")]
328    ParserExhausted(usize, usize),
329}
330
331#[cfg(test)]
332#[allow(clippy::unwrap_used)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_default_limits() {
338        let limits = ExecutionLimits::default();
339        assert_eq!(limits.max_commands, 10_000);
340        assert_eq!(limits.max_loop_iterations, 10_000);
341        assert_eq!(limits.max_total_loop_iterations, 1_000_000);
342        assert_eq!(limits.max_function_depth, 100);
343        assert_eq!(limits.timeout, Duration::from_secs(30));
344        assert_eq!(limits.parser_timeout, Duration::from_secs(5));
345        assert_eq!(limits.max_input_bytes, 10_000_000);
346        assert_eq!(limits.max_ast_depth, 100);
347        assert_eq!(limits.max_parser_operations, 100_000);
348    }
349
350    #[test]
351    fn test_builder_pattern() {
352        let limits = ExecutionLimits::new()
353            .max_commands(100)
354            .max_loop_iterations(50)
355            .max_function_depth(10)
356            .timeout(Duration::from_secs(5));
357
358        assert_eq!(limits.max_commands, 100);
359        assert_eq!(limits.max_loop_iterations, 50);
360        assert_eq!(limits.max_function_depth, 10);
361        assert_eq!(limits.timeout, Duration::from_secs(5));
362    }
363
364    #[test]
365    fn test_command_counter() {
366        let limits = ExecutionLimits::new().max_commands(5);
367        let mut counters = ExecutionCounters::new();
368
369        for _ in 0..5 {
370            assert!(counters.tick_command(&limits).is_ok());
371        }
372
373        // 6th command should fail
374        assert!(matches!(
375            counters.tick_command(&limits),
376            Err(LimitExceeded::MaxCommands(5))
377        ));
378    }
379
380    #[test]
381    fn test_loop_counter() {
382        let limits = ExecutionLimits::new().max_loop_iterations(3);
383        let mut counters = ExecutionCounters::new();
384
385        for _ in 0..3 {
386            assert!(counters.tick_loop(&limits).is_ok());
387        }
388
389        // 4th iteration should fail
390        assert!(matches!(
391            counters.tick_loop(&limits),
392            Err(LimitExceeded::MaxLoopIterations(3))
393        ));
394
395        // Reset and try again
396        counters.reset_loop();
397        assert!(counters.tick_loop(&limits).is_ok());
398    }
399
400    #[test]
401    fn test_total_loop_counter_accumulates() {
402        let limits = ExecutionLimits::new()
403            .max_loop_iterations(5)
404            .max_total_loop_iterations(8);
405        let mut counters = ExecutionCounters::new();
406
407        // First loop: 5 iterations (per-loop limit)
408        for _ in 0..5 {
409            assert!(counters.tick_loop(&limits).is_ok());
410        }
411        assert_eq!(counters.total_loop_iterations, 5);
412
413        // Reset per-loop counter (entering new loop)
414        counters.reset_loop();
415        assert_eq!(counters.loop_iterations, 0);
416        // total_loop_iterations should NOT reset
417        assert_eq!(counters.total_loop_iterations, 5);
418
419        // Second loop: should fail after 3 more (total = 8 cap)
420        assert!(counters.tick_loop(&limits).is_ok()); // total=6
421        assert!(counters.tick_loop(&limits).is_ok()); // total=7
422        assert!(counters.tick_loop(&limits).is_ok()); // total=8
423
424        // 9th total iteration should fail
425        assert!(matches!(
426            counters.tick_loop(&limits),
427            Err(LimitExceeded::MaxTotalLoopIterations(8))
428        ));
429    }
430
431    #[test]
432    fn test_function_depth() {
433        let limits = ExecutionLimits::new().max_function_depth(2);
434        let mut counters = ExecutionCounters::new();
435
436        assert!(counters.push_function(&limits).is_ok());
437        assert!(counters.push_function(&limits).is_ok());
438
439        // 3rd call should fail
440        assert!(matches!(
441            counters.push_function(&limits),
442            Err(LimitExceeded::MaxFunctionDepth(2))
443        ));
444
445        // Pop and try again
446        counters.pop_function();
447        assert!(counters.push_function(&limits).is_ok());
448    }
449
450    #[test]
451    fn test_reset_for_execution() {
452        let limits = ExecutionLimits::new().max_commands(5);
453        let mut counters = ExecutionCounters::new();
454
455        // Exhaust command budget
456        for _ in 0..5 {
457            counters.tick_command(&limits).unwrap();
458        }
459        assert!(counters.tick_command(&limits).is_err());
460
461        // Also accumulate some loop/function state
462        counters.loop_iterations = 42;
463        counters.total_loop_iterations = 999;
464        counters.function_depth = 3;
465
466        // Reset should restore all counters
467        counters.reset_for_execution();
468        assert_eq!(counters.commands, 0);
469        assert_eq!(counters.loop_iterations, 0);
470        assert_eq!(counters.total_loop_iterations, 0);
471        assert_eq!(counters.function_depth, 0);
472
473        // Should be able to tick commands again
474        assert!(counters.tick_command(&limits).is_ok());
475    }
476}