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    /// Maximum stdout capture size in bytes
78    /// Default: 1MB (1,048,576 bytes)
79    /// Prevents unbounded output accumulation from runaway commands.
80    pub max_stdout_bytes: usize,
81
82    /// Maximum stderr capture size in bytes
83    /// Default: 1MB (1,048,576 bytes)
84    /// Prevents unbounded error output accumulation.
85    pub max_stderr_bytes: usize,
86
87    /// Whether to capture the final environment state in ExecResult.
88    /// Default: false (opt-in to avoid cloning cost when not needed)
89    pub capture_final_env: bool,
90}
91
92impl Default for ExecutionLimits {
93    fn default() -> Self {
94        Self {
95            max_commands: 10_000,
96            max_loop_iterations: 10_000,
97            max_total_loop_iterations: 1_000_000,
98            max_function_depth: 100,
99            timeout: Duration::from_secs(30),
100            parser_timeout: Duration::from_secs(5),
101            max_input_bytes: 10_000_000, // 10MB
102            max_ast_depth: 100,
103            max_parser_operations: 100_000,
104            max_stdout_bytes: 1_048_576, // 1MB
105            max_stderr_bytes: 1_048_576, // 1MB
106            capture_final_env: false,
107        }
108    }
109}
110
111impl ExecutionLimits {
112    /// Create new limits with defaults
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Set maximum command count
118    pub fn max_commands(mut self, count: usize) -> Self {
119        self.max_commands = count;
120        self
121    }
122
123    /// Set maximum loop iterations (per-loop)
124    pub fn max_loop_iterations(mut self, count: usize) -> Self {
125        self.max_loop_iterations = count;
126        self
127    }
128
129    /// Set maximum total loop iterations (across all nested/sequential loops).
130    /// Prevents TM-DOS-018 nested loop multiplication.
131    pub fn max_total_loop_iterations(mut self, count: usize) -> Self {
132        self.max_total_loop_iterations = count;
133        self
134    }
135
136    /// Set maximum function depth
137    pub fn max_function_depth(mut self, depth: usize) -> Self {
138        self.max_function_depth = depth;
139        self
140    }
141
142    /// Set execution timeout
143    pub fn timeout(mut self, timeout: Duration) -> Self {
144        self.timeout = timeout;
145        self
146    }
147
148    /// Set parser timeout
149    pub fn parser_timeout(mut self, timeout: Duration) -> Self {
150        self.parser_timeout = timeout;
151        self
152    }
153
154    /// Set maximum input script size in bytes
155    pub fn max_input_bytes(mut self, bytes: usize) -> Self {
156        self.max_input_bytes = bytes;
157        self
158    }
159
160    /// Set maximum AST nesting depth
161    pub fn max_ast_depth(mut self, depth: usize) -> Self {
162        self.max_ast_depth = depth;
163        self
164    }
165
166    /// Set maximum parser operations
167    pub fn max_parser_operations(mut self, ops: usize) -> Self {
168        self.max_parser_operations = ops;
169        self
170    }
171
172    /// Set maximum stdout capture size in bytes
173    pub fn max_stdout_bytes(mut self, bytes: usize) -> Self {
174        self.max_stdout_bytes = bytes;
175        self
176    }
177
178    /// Set maximum stderr capture size in bytes
179    pub fn max_stderr_bytes(mut self, bytes: usize) -> Self {
180        self.max_stderr_bytes = bytes;
181        self
182    }
183
184    /// Enable capturing final environment state in ExecResult
185    pub fn capture_final_env(mut self, capture: bool) -> Self {
186        self.capture_final_env = capture;
187        self
188    }
189}
190
191// THREAT[TM-DOS-059]: Session-level cumulative resource limits.
192// Per-exec limits reset every exec() call. Session limits persist across
193// all exec() calls within a Bash instance, preventing a tenant from
194// circumventing per-execution limits by splitting work across many calls.
195
196/// Default max total commands across all exec() calls: 100,000
197pub const DEFAULT_SESSION_MAX_COMMANDS: u64 = 100_000;
198
199/// Default max exec() invocations per session: 1,000
200pub const DEFAULT_SESSION_MAX_EXEC_CALLS: u64 = 1_000;
201
202/// Session-level resource limits that persist across `exec()` calls.
203///
204/// These limits prevent tenants from circumventing per-execution limits
205/// by splitting work across many small `exec()` calls.
206#[derive(Debug, Clone)]
207pub struct SessionLimits {
208    /// Maximum total commands across all exec() calls.
209    /// Default: 100,000
210    pub max_total_commands: u64,
211
212    /// Maximum number of exec() invocations per session.
213    /// Default: 1,000
214    pub max_exec_calls: u64,
215}
216
217impl Default for SessionLimits {
218    fn default() -> Self {
219        Self {
220            max_total_commands: DEFAULT_SESSION_MAX_COMMANDS,
221            max_exec_calls: DEFAULT_SESSION_MAX_EXEC_CALLS,
222        }
223    }
224}
225
226impl SessionLimits {
227    /// Create new session limits with defaults.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Set maximum total commands across all exec() calls.
233    pub fn max_total_commands(mut self, count: u64) -> Self {
234        self.max_total_commands = count;
235        self
236    }
237
238    /// Set maximum number of exec() invocations.
239    pub fn max_exec_calls(mut self, count: u64) -> Self {
240        self.max_exec_calls = count;
241        self
242    }
243
244    /// Create unlimited session limits (no restrictions).
245    pub fn unlimited() -> Self {
246        Self {
247            max_total_commands: u64::MAX,
248            max_exec_calls: u64::MAX,
249        }
250    }
251}
252
253/// Execution counters for tracking resource usage
254#[derive(Debug, Clone, Default)]
255pub struct ExecutionCounters {
256    /// Number of commands executed
257    pub commands: usize,
258
259    /// Current function call depth
260    pub function_depth: usize,
261
262    /// Number of iterations in current loop (reset per-loop)
263    pub loop_iterations: usize,
264
265    // THREAT[TM-DOS-018]: Nested loop multiplication
266    // This counter never resets, tracking total iterations across all loops.
267    /// Total loop iterations across all loops (never reset)
268    pub total_loop_iterations: usize,
269
270    // THREAT[TM-DOS-059]: Session-level cumulative counters.
271    // These persist across exec() calls (never reset by reset_for_execution).
272    /// Total commands across all exec() calls in this session.
273    pub session_commands: u64,
274
275    /// Number of exec() invocations in this session.
276    pub session_exec_calls: u64,
277}
278
279impl ExecutionCounters {
280    /// Create new counters
281    pub fn new() -> Self {
282        Self::default()
283    }
284
285    /// Reset counters for a new exec() invocation.
286    /// Each exec() is a separate script and gets its own budget.
287    /// This prevents a prior exec() from permanently poisoning the session.
288    pub fn reset_for_execution(&mut self) {
289        self.commands = 0;
290        self.loop_iterations = 0;
291        self.total_loop_iterations = 0;
292        // function_depth should already be 0 between exec() calls,
293        // but reset defensively to avoid stuck state
294        self.function_depth = 0;
295    }
296
297    /// Increment command counter, returns error if limit exceeded
298    pub fn tick_command(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
299        // Fail point: test behavior when counter increment is corrupted
300        #[cfg(feature = "failpoints")]
301        fail_point!("limits::tick_command", |action| {
302            match action.as_deref() {
303                Some("skip_increment") => {
304                    // Simulate counter not incrementing (potential bypass)
305                    return Ok(());
306                }
307                Some("force_overflow") => {
308                    // Simulate counter overflow
309                    self.commands = usize::MAX;
310                    return Err(LimitExceeded::MaxCommands(limits.max_commands));
311                }
312                Some("corrupt_high") => {
313                    // Simulate counter corruption to a high value
314                    self.commands = limits.max_commands + 1;
315                }
316                _ => {}
317            }
318            Ok(())
319        });
320
321        self.commands += 1;
322        self.session_commands += 1;
323        if self.commands > limits.max_commands {
324            return Err(LimitExceeded::MaxCommands(limits.max_commands));
325        }
326        Ok(())
327    }
328
329    /// Check session-level limits. Called at exec() entry and during execution.
330    pub fn check_session_limits(
331        &self,
332        session_limits: &SessionLimits,
333    ) -> Result<(), LimitExceeded> {
334        if self.session_exec_calls > session_limits.max_exec_calls {
335            return Err(LimitExceeded::SessionMaxExecCalls(
336                session_limits.max_exec_calls,
337            ));
338        }
339        if self.session_commands > session_limits.max_total_commands {
340            return Err(LimitExceeded::SessionMaxCommands(
341                session_limits.max_total_commands,
342            ));
343        }
344        Ok(())
345    }
346
347    /// Increment exec call counter for session tracking.
348    pub fn tick_exec_call(&mut self) {
349        self.session_exec_calls += 1;
350    }
351
352    /// Increment loop iteration counter, returns error if limit exceeded
353    pub fn tick_loop(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
354        // Fail point: test behavior when loop counter is corrupted
355        #[cfg(feature = "failpoints")]
356        fail_point!("limits::tick_loop", |action| {
357            match action.as_deref() {
358                Some("skip_check") => {
359                    // Simulate limit check being bypassed
360                    self.loop_iterations += 1;
361                    return Ok(());
362                }
363                Some("reset_counter") => {
364                    // Simulate counter being reset (infinite loop potential)
365                    self.loop_iterations = 0;
366                    return Ok(());
367                }
368                _ => {}
369            }
370            Ok(())
371        });
372
373        self.loop_iterations += 1;
374        self.total_loop_iterations += 1;
375        if self.loop_iterations > limits.max_loop_iterations {
376            return Err(LimitExceeded::MaxLoopIterations(limits.max_loop_iterations));
377        }
378        // THREAT[TM-DOS-018]: Check global cap to prevent nested loop multiplication
379        if self.total_loop_iterations > limits.max_total_loop_iterations {
380            return Err(LimitExceeded::MaxTotalLoopIterations(
381                limits.max_total_loop_iterations,
382            ));
383        }
384        Ok(())
385    }
386
387    /// Reset loop iteration counter (called when entering a new loop)
388    pub fn reset_loop(&mut self) {
389        self.loop_iterations = 0;
390    }
391
392    /// Push function call, returns error if depth exceeded
393    pub fn push_function(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> {
394        // Fail point: test behavior when function depth tracking fails
395        #[cfg(feature = "failpoints")]
396        fail_point!("limits::push_function", |action| {
397            match action.as_deref() {
398                Some("skip_check") => {
399                    // Simulate depth check being bypassed (stack overflow potential)
400                    self.function_depth += 1;
401                    return Ok(());
402                }
403                Some("corrupt_depth") => {
404                    // Simulate depth counter corruption
405                    self.function_depth = 0;
406                    return Ok(());
407                }
408                _ => {}
409            }
410            Ok(())
411        });
412
413        // Check before incrementing so we don't leave invalid state on failure
414        if self.function_depth >= limits.max_function_depth {
415            return Err(LimitExceeded::MaxFunctionDepth(limits.max_function_depth));
416        }
417        self.function_depth += 1;
418        Ok(())
419    }
420
421    /// Pop function call
422    pub fn pop_function(&mut self) {
423        if self.function_depth > 0 {
424            self.function_depth -= 1;
425        }
426    }
427}
428
429/// Error returned when a resource limit is exceeded
430#[derive(Debug, Clone, thiserror::Error)]
431pub enum LimitExceeded {
432    #[error("maximum command count exceeded ({0})")]
433    MaxCommands(usize),
434
435    #[error("maximum loop iterations exceeded ({0})")]
436    MaxLoopIterations(usize),
437
438    #[error("maximum total loop iterations exceeded ({0})")]
439    MaxTotalLoopIterations(usize),
440
441    #[error("maximum function depth exceeded ({0})")]
442    MaxFunctionDepth(usize),
443
444    #[error("execution timeout ({0:?})")]
445    Timeout(Duration),
446
447    #[error("parser timeout ({0:?})")]
448    ParserTimeout(Duration),
449
450    #[error("input too large ({0} bytes, max {1} bytes)")]
451    InputTooLarge(usize, usize),
452
453    #[error("AST nesting too deep ({0} levels, max {1})")]
454    AstTooDeep(usize, usize),
455
456    #[error("parser fuel exhausted ({0} operations, max {1})")]
457    ParserExhausted(usize, usize),
458
459    #[error("session command limit exceeded ({0} total commands)")]
460    SessionMaxCommands(u64),
461
462    #[error("session exec() call limit exceeded ({0} calls)")]
463    SessionMaxExecCalls(u64),
464
465    #[error("memory limit exceeded: {0}")]
466    Memory(String),
467}
468
469// THREAT[TM-DOS-060]: Per-instance memory budget.
470// Without limits, a script can create unbounded variables, arrays, and
471// functions, consuming arbitrary heap memory and OOMing a multi-tenant process.
472
473/// Default max variable count (scalar variables).
474pub const DEFAULT_MAX_VARIABLE_COUNT: usize = 10_000;
475/// Default max total variable bytes (keys + values).
476pub const DEFAULT_MAX_TOTAL_VARIABLE_BYTES: usize = 10_000_000; // 10MB
477/// Default max array entries (total across all indexed + associative arrays).
478pub const DEFAULT_MAX_ARRAY_ENTRIES: usize = 100_000;
479/// Default max function definitions.
480pub const DEFAULT_MAX_FUNCTION_COUNT: usize = 1_000;
481/// Default max total function body bytes (source text).
482pub const DEFAULT_MAX_FUNCTION_BODY_BYTES: usize = 1_000_000; // 1MB
483
484/// Memory limits for a Bash instance.
485///
486/// Controls the maximum amount of interpreter-level memory
487/// (variables, arrays, functions) a single instance can consume.
488#[derive(Debug, Clone)]
489pub struct MemoryLimits {
490    /// Maximum number of scalar variables.
491    pub max_variable_count: usize,
492    /// Maximum total bytes across all variable keys + values.
493    pub max_total_variable_bytes: usize,
494    /// Maximum total entries across all indexed and associative arrays.
495    pub max_array_entries: usize,
496    /// Maximum number of function definitions.
497    pub max_function_count: usize,
498    /// Maximum total bytes of function body source text.
499    pub max_function_body_bytes: usize,
500}
501
502impl Default for MemoryLimits {
503    fn default() -> Self {
504        Self {
505            max_variable_count: DEFAULT_MAX_VARIABLE_COUNT,
506            max_total_variable_bytes: DEFAULT_MAX_TOTAL_VARIABLE_BYTES,
507            max_array_entries: DEFAULT_MAX_ARRAY_ENTRIES,
508            max_function_count: DEFAULT_MAX_FUNCTION_COUNT,
509            max_function_body_bytes: DEFAULT_MAX_FUNCTION_BODY_BYTES,
510        }
511    }
512}
513
514impl MemoryLimits {
515    /// Create new memory limits with defaults.
516    pub fn new() -> Self {
517        Self::default()
518    }
519
520    /// Set maximum variable count.
521    pub fn max_variable_count(mut self, count: usize) -> Self {
522        self.max_variable_count = count;
523        self
524    }
525
526    /// Set maximum total variable bytes.
527    pub fn max_total_variable_bytes(mut self, bytes: usize) -> Self {
528        self.max_total_variable_bytes = bytes;
529        self
530    }
531
532    /// Set maximum array entries.
533    pub fn max_array_entries(mut self, count: usize) -> Self {
534        self.max_array_entries = count;
535        self
536    }
537
538    /// Set maximum function count.
539    pub fn max_function_count(mut self, count: usize) -> Self {
540        self.max_function_count = count;
541        self
542    }
543
544    /// Set maximum function body bytes.
545    pub fn max_function_body_bytes(mut self, bytes: usize) -> Self {
546        self.max_function_body_bytes = bytes;
547        self
548    }
549
550    /// Create unlimited memory limits.
551    pub fn unlimited() -> Self {
552        Self {
553            max_variable_count: usize::MAX,
554            max_total_variable_bytes: usize::MAX,
555            max_array_entries: usize::MAX,
556            max_function_count: usize::MAX,
557            max_function_body_bytes: usize::MAX,
558        }
559    }
560}
561
562/// Tracks approximate memory usage for budget enforcement.
563#[derive(Debug, Clone, Default)]
564pub struct MemoryBudget {
565    /// Number of scalar variables (excluding internal markers).
566    pub variable_count: usize,
567    /// Total bytes in variable keys + values.
568    pub variable_bytes: usize,
569    /// Total entries across all arrays (indexed + associative).
570    pub array_entries: usize,
571    /// Number of function definitions.
572    pub function_count: usize,
573    /// Total bytes in function bodies.
574    pub function_body_bytes: usize,
575}
576
577impl MemoryBudget {
578    /// Check if adding a variable would exceed limits.
579    pub fn check_variable_insert(
580        &self,
581        key_len: usize,
582        value_len: usize,
583        is_new: bool,
584        old_key_len: usize,
585        old_value_len: usize,
586        limits: &MemoryLimits,
587    ) -> Result<(), LimitExceeded> {
588        if is_new && self.variable_count >= limits.max_variable_count {
589            return Err(LimitExceeded::Memory(format!(
590                "variable count limit ({}) exceeded",
591                limits.max_variable_count
592            )));
593        }
594        let new_bytes =
595            (self.variable_bytes + key_len + value_len).saturating_sub(old_key_len + old_value_len);
596        if new_bytes > limits.max_total_variable_bytes {
597            return Err(LimitExceeded::Memory(format!(
598                "variable byte limit ({}) exceeded",
599                limits.max_total_variable_bytes
600            )));
601        }
602        Ok(())
603    }
604
605    /// Record a variable insert (call after successful insert).
606    pub fn record_variable_insert(
607        &mut self,
608        key_len: usize,
609        value_len: usize,
610        is_new: bool,
611        old_key_len: usize,
612        old_value_len: usize,
613    ) {
614        if is_new {
615            self.variable_count += 1;
616        }
617        self.variable_bytes =
618            (self.variable_bytes + key_len + value_len).saturating_sub(old_key_len + old_value_len);
619    }
620
621    /// Record a variable removal.
622    pub fn record_variable_remove(&mut self, key_len: usize, value_len: usize) {
623        self.variable_count = self.variable_count.saturating_sub(1);
624        self.variable_bytes = self.variable_bytes.saturating_sub(key_len + value_len);
625    }
626
627    /// Check if adding array entries would exceed limits.
628    pub fn check_array_entries(
629        &self,
630        additional: usize,
631        limits: &MemoryLimits,
632    ) -> Result<(), LimitExceeded> {
633        if self.array_entries + additional > limits.max_array_entries {
634            return Err(LimitExceeded::Memory(format!(
635                "array entry limit ({}) exceeded",
636                limits.max_array_entries
637            )));
638        }
639        Ok(())
640    }
641
642    /// Record array entry changes.
643    pub fn record_array_insert(&mut self, added: usize) {
644        self.array_entries += added;
645    }
646
647    /// Record array entry removal.
648    pub fn record_array_remove(&mut self, removed: usize) {
649        self.array_entries = self.array_entries.saturating_sub(removed);
650    }
651
652    /// Check if adding a function would exceed limits.
653    pub fn check_function_insert(
654        &self,
655        body_bytes: usize,
656        is_new: bool,
657        old_body_bytes: usize,
658        limits: &MemoryLimits,
659    ) -> Result<(), LimitExceeded> {
660        if is_new && self.function_count >= limits.max_function_count {
661            return Err(LimitExceeded::Memory(format!(
662                "function count limit ({}) exceeded",
663                limits.max_function_count
664            )));
665        }
666        let new_bytes = self.function_body_bytes + body_bytes - old_body_bytes;
667        if new_bytes > limits.max_function_body_bytes {
668            return Err(LimitExceeded::Memory(format!(
669                "function body byte limit ({}) exceeded",
670                limits.max_function_body_bytes
671            )));
672        }
673        Ok(())
674    }
675
676    /// Record a function insert.
677    pub fn record_function_insert(
678        &mut self,
679        body_bytes: usize,
680        is_new: bool,
681        old_body_bytes: usize,
682    ) {
683        if is_new {
684            self.function_count += 1;
685        }
686        self.function_body_bytes =
687            (self.function_body_bytes + body_bytes).saturating_sub(old_body_bytes);
688    }
689
690    /// Record a function removal.
691    pub fn record_function_remove(&mut self, body_bytes: usize) {
692        self.function_count = self.function_count.saturating_sub(1);
693        self.function_body_bytes = self.function_body_bytes.saturating_sub(body_bytes);
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_default_limits() {
703        let limits = ExecutionLimits::default();
704        assert_eq!(limits.max_commands, 10_000);
705        assert_eq!(limits.max_loop_iterations, 10_000);
706        assert_eq!(limits.max_total_loop_iterations, 1_000_000);
707        assert_eq!(limits.max_function_depth, 100);
708        assert_eq!(limits.timeout, Duration::from_secs(30));
709        assert_eq!(limits.parser_timeout, Duration::from_secs(5));
710        assert_eq!(limits.max_input_bytes, 10_000_000);
711        assert_eq!(limits.max_ast_depth, 100);
712        assert_eq!(limits.max_parser_operations, 100_000);
713        assert_eq!(limits.max_stdout_bytes, 1_048_576);
714        assert_eq!(limits.max_stderr_bytes, 1_048_576);
715        assert!(!limits.capture_final_env);
716    }
717
718    #[test]
719    fn test_builder_pattern() {
720        let limits = ExecutionLimits::new()
721            .max_commands(100)
722            .max_loop_iterations(50)
723            .max_function_depth(10)
724            .timeout(Duration::from_secs(5));
725
726        assert_eq!(limits.max_commands, 100);
727        assert_eq!(limits.max_loop_iterations, 50);
728        assert_eq!(limits.max_function_depth, 10);
729        assert_eq!(limits.timeout, Duration::from_secs(5));
730    }
731
732    #[test]
733    fn test_command_counter() {
734        let limits = ExecutionLimits::new().max_commands(5);
735        let mut counters = ExecutionCounters::new();
736
737        for _ in 0..5 {
738            assert!(counters.tick_command(&limits).is_ok());
739        }
740
741        // 6th command should fail
742        assert!(matches!(
743            counters.tick_command(&limits),
744            Err(LimitExceeded::MaxCommands(5))
745        ));
746    }
747
748    #[test]
749    fn test_loop_counter() {
750        let limits = ExecutionLimits::new().max_loop_iterations(3);
751        let mut counters = ExecutionCounters::new();
752
753        for _ in 0..3 {
754            assert!(counters.tick_loop(&limits).is_ok());
755        }
756
757        // 4th iteration should fail
758        assert!(matches!(
759            counters.tick_loop(&limits),
760            Err(LimitExceeded::MaxLoopIterations(3))
761        ));
762
763        // Reset and try again
764        counters.reset_loop();
765        assert!(counters.tick_loop(&limits).is_ok());
766    }
767
768    #[test]
769    fn test_total_loop_counter_accumulates() {
770        let limits = ExecutionLimits::new()
771            .max_loop_iterations(5)
772            .max_total_loop_iterations(8);
773        let mut counters = ExecutionCounters::new();
774
775        // First loop: 5 iterations (per-loop limit)
776        for _ in 0..5 {
777            assert!(counters.tick_loop(&limits).is_ok());
778        }
779        assert_eq!(counters.total_loop_iterations, 5);
780
781        // Reset per-loop counter (entering new loop)
782        counters.reset_loop();
783        assert_eq!(counters.loop_iterations, 0);
784        // total_loop_iterations should NOT reset
785        assert_eq!(counters.total_loop_iterations, 5);
786
787        // Second loop: should fail after 3 more (total = 8 cap)
788        assert!(counters.tick_loop(&limits).is_ok()); // total=6
789        assert!(counters.tick_loop(&limits).is_ok()); // total=7
790        assert!(counters.tick_loop(&limits).is_ok()); // total=8
791
792        // 9th total iteration should fail
793        assert!(matches!(
794            counters.tick_loop(&limits),
795            Err(LimitExceeded::MaxTotalLoopIterations(8))
796        ));
797    }
798
799    #[test]
800    fn test_function_depth() {
801        let limits = ExecutionLimits::new().max_function_depth(2);
802        let mut counters = ExecutionCounters::new();
803
804        assert!(counters.push_function(&limits).is_ok());
805        assert!(counters.push_function(&limits).is_ok());
806
807        // 3rd call should fail
808        assert!(matches!(
809            counters.push_function(&limits),
810            Err(LimitExceeded::MaxFunctionDepth(2))
811        ));
812
813        // Pop and try again
814        counters.pop_function();
815        assert!(counters.push_function(&limits).is_ok());
816    }
817
818    #[test]
819    fn test_reset_for_execution() {
820        let limits = ExecutionLimits::new().max_commands(5);
821        let mut counters = ExecutionCounters::new();
822
823        // Exhaust command budget
824        for _ in 0..5 {
825            counters.tick_command(&limits).unwrap();
826        }
827        assert!(counters.tick_command(&limits).is_err());
828
829        // Also accumulate some loop/function state
830        counters.loop_iterations = 42;
831        counters.total_loop_iterations = 999;
832        counters.function_depth = 3;
833
834        // Reset should restore all counters
835        counters.reset_for_execution();
836        assert_eq!(counters.commands, 0);
837        assert_eq!(counters.loop_iterations, 0);
838        assert_eq!(counters.total_loop_iterations, 0);
839        assert_eq!(counters.function_depth, 0);
840
841        // Should be able to tick commands again
842        assert!(counters.tick_command(&limits).is_ok());
843    }
844}