1use std::{
8 collections::VecDeque,
9 time::{Duration, Instant},
10};
11
12pub const DEFAULT_MAX_STEPS: u64 = 100_000;
14pub const DEFAULT_MAX_RECURSION_DEPTH: u32 = 64;
16pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 256 * 1024;
18pub const DEFAULT_MAX_OUTPUT_LINES: usize = 2_000;
20pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22pub const DEFAULT_MAX_CAPABILITY_CALLS: u32 = 32;
24pub const DEFAULT_MAX_VALUE_BYTES: u64 = 32 * 1024 * 1024;
26pub const DEFAULT_ALLOW_CLOCK: bool = false;
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct Limits {
32 pub max_steps: u64,
34 pub max_recursion_depth: u32,
36 pub max_output_bytes: usize,
38 pub max_output_lines: usize,
40 pub timeout: Duration,
42 pub max_capability_calls: u32,
44 pub max_value_bytes: u64,
46 pub allow_clock: bool,
54}
55
56impl Default for Limits {
57 fn default() -> Self {
58 Self {
59 max_steps: DEFAULT_MAX_STEPS,
60 max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH,
61 max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
62 max_output_lines: DEFAULT_MAX_OUTPUT_LINES,
63 timeout: DEFAULT_TIMEOUT,
64 max_capability_calls: DEFAULT_MAX_CAPABILITY_CALLS,
65 max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
66 allow_clock: DEFAULT_ALLOW_CLOCK,
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum LimitExceeded {
74 Steps {
76 maximum: u64,
78 },
79 RecursionDepth {
81 maximum: u32,
83 },
84 Deadline {
86 timeout_ms: u128,
88 },
89 CapabilityCalls {
91 maximum: u32,
93 },
94 ValueBytes {
96 maximum: u64,
98 },
99}
100
101#[derive(Debug)]
103pub struct Budget {
104 limits: Limits,
105 started: Instant,
106 steps: u64,
107 depth: u32,
108 capability_calls: u32,
109 value_bytes: u64,
110}
111
112impl Budget {
113 #[must_use]
115 pub fn start(limits: Limits) -> Self {
116 Self {
117 limits,
118 started: Instant::now(),
119 steps: 0,
120 depth: 0,
121 capability_calls: 0,
122 value_bytes: 0,
123 }
124 }
125
126 pub fn charge_step(&mut self) -> Result<(), LimitExceeded> {
134 self.steps = self.steps.saturating_add(1);
135 if self.steps > self.limits.max_steps {
136 return Err(LimitExceeded::Steps {
137 maximum: self.limits.max_steps,
138 });
139 }
140 self.check_deadline()
141 }
142
143 pub fn charge_value_bytes(&mut self, bytes: u64) -> Result<(), LimitExceeded> {
152 self.value_bytes = self.value_bytes.saturating_add(bytes);
153 if self.value_bytes > self.limits.max_value_bytes {
154 return Err(LimitExceeded::ValueBytes {
155 maximum: self.limits.max_value_bytes,
156 });
157 }
158 Ok(())
159 }
160
161 pub fn check_deadline(&self) -> Result<(), LimitExceeded> {
163 if self.started.elapsed() > self.limits.timeout {
164 return Err(LimitExceeded::Deadline {
165 timeout_ms: self.limits.timeout.as_millis(),
166 });
167 }
168 Ok(())
169 }
170
171 #[must_use]
173 pub fn remaining(&self) -> Duration {
174 self.limits.timeout.saturating_sub(self.started.elapsed())
175 }
176
177 pub fn enter_call(&mut self) -> Result<(), LimitExceeded> {
179 if self.depth >= self.limits.max_recursion_depth {
180 return Err(LimitExceeded::RecursionDepth {
181 maximum: self.limits.max_recursion_depth,
182 });
183 }
184 self.depth = self.depth.saturating_add(1);
185 Ok(())
186 }
187
188 pub fn leave_call(&mut self) {
190 self.depth = self.depth.saturating_sub(1);
191 }
192
193 pub fn charge_capability_call(&mut self) -> Result<(), LimitExceeded> {
199 if self.capability_calls >= self.limits.max_capability_calls {
200 return Err(LimitExceeded::CapabilityCalls {
201 maximum: self.limits.max_capability_calls,
202 });
203 }
204 self.capability_calls = self.capability_calls.saturating_add(1);
205 Ok(())
206 }
207
208 #[must_use]
210 pub fn capability_calls(&self) -> u32 {
211 self.capability_calls
212 }
213
214 #[must_use]
216 pub fn steps(&self) -> u64 {
217 self.steps
218 }
219
220 #[must_use]
222 pub fn value_bytes(&self) -> u64 {
223 self.value_bytes
224 }
225}
226
227#[derive(Debug)]
234pub struct OutputBuffer {
235 max_bytes: usize,
236 max_lines: usize,
237 head: Vec<String>,
238 head_bytes: usize,
239 tail: VecDeque<String>,
240 tail_bytes: usize,
241 total_lines: usize,
242 truncated: bool,
243 pending: String,
244}
245
246impl OutputBuffer {
247 #[must_use]
249 pub fn new(limits: &Limits) -> Self {
250 Self {
251 max_bytes: limits.max_output_bytes.max(1),
252 max_lines: limits.max_output_lines.max(1),
253 head: Vec::new(),
254 head_bytes: 0,
255 tail: VecDeque::new(),
256 tail_bytes: 0,
257 total_lines: 0,
258 truncated: false,
259 pending: String::new(),
260 }
261 }
262
263 fn tail_line_budget(&self) -> usize {
264 (self.max_lines / 2).max(1)
265 }
266
267 fn tail_byte_budget(&self) -> usize {
268 (self.max_bytes / 2).max(1)
269 }
270
271 pub fn push_line(&mut self, line: &str) {
273 let line = clamp_line(line, self.max_bytes);
274 let cost = line.len().saturating_add(1);
275 self.total_lines = self.total_lines.saturating_add(1);
276
277 if !self.truncated {
278 let fits = self.head.len() < self.max_lines
279 && self.head_bytes.saturating_add(cost) <= self.max_bytes;
280 if fits {
281 self.head_bytes = self.head_bytes.saturating_add(cost);
282 self.head.push(line);
283 return;
284 }
285 self.begin_truncation();
286 }
287
288 self.tail_bytes = self.tail_bytes.saturating_add(cost);
289 self.tail.push_back(line);
290 self.evict_tail();
291 }
292
293 pub fn push_fragment(&mut self, fragment: &str) {
298 self.pending.push_str(fragment);
299 self.drain_complete_lines();
300 }
301
302 pub fn push_block(&mut self, block: &str) {
304 self.pending.push_str(block);
305 self.pending.push('\n');
306 self.drain_complete_lines();
307 }
308
309 fn drain_complete_lines(&mut self) {
310 while let Some(offset) = self.pending.find('\n') {
311 let line = self.pending[..offset].to_owned();
312 self.pending.drain(..=offset);
313 self.push_line(&line);
314 }
315 }
316
317 pub fn finish(&mut self) {
319 if !self.pending.is_empty() {
320 let line = std::mem::take(&mut self.pending);
321 self.push_line(&line);
322 }
323 }
324
325 fn begin_truncation(&mut self) {
326 self.truncated = true;
327 let head_lines = self.max_lines.saturating_sub(self.tail_line_budget());
328 let head_bytes = self.max_bytes.saturating_sub(self.tail_byte_budget());
329 while self.head.len() > head_lines || self.head_bytes > head_bytes {
330 let Some(dropped) = self.head.pop() else {
331 break;
332 };
333 self.head_bytes = self
334 .head_bytes
335 .saturating_sub(dropped.len().saturating_add(1));
336 }
337 }
338
339 fn evict_tail(&mut self) {
340 while self.tail.len() > self.tail_line_budget() || self.tail_bytes > self.tail_byte_budget()
341 {
342 let Some(dropped) = self.tail.pop_front() else {
343 break;
344 };
345 self.tail_bytes = self
346 .tail_bytes
347 .saturating_sub(dropped.len().saturating_add(1));
348 if self.tail.is_empty() {
349 break;
350 }
351 }
352 }
353
354 #[must_use]
356 pub fn is_truncated(&self) -> bool {
357 self.truncated
358 }
359
360 #[must_use]
362 pub fn render(&self) -> String {
363 let mut lines = Vec::with_capacity(self.head.len() + self.tail.len() + 1);
364 lines.extend(self.head.iter().cloned());
365 if self.truncated {
366 lines.push(format!(
367 "... Output truncated ({} total lines) ...",
368 self.total_lines
369 ));
370 lines.extend(self.tail.iter().cloned());
371 }
372 lines.join("\n")
373 }
374}
375
376fn clamp_line(line: &str, maximum: usize) -> String {
378 if line.len() <= maximum {
379 return line.to_owned();
380 }
381 let mut end = maximum;
382 while end > 0 && !line.is_char_boundary(end) {
383 end -= 1;
384 }
385 format!("{}...", &line[..end])
386}
387
388#[cfg(test)]
389mod tests {
390 use std::time::Duration;
391
392 use super::{Budget, LimitExceeded, Limits, OutputBuffer};
393
394 #[test]
395 fn step_budget_trips_at_the_configured_ceiling() {
396 let mut budget = Budget::start(Limits {
397 max_steps: 3,
398 ..Limits::default()
399 });
400 assert!(budget.charge_step().is_ok());
401 assert!(budget.charge_step().is_ok());
402 assert!(budget.charge_step().is_ok());
403 assert_eq!(
404 budget.charge_step(),
405 Err(LimitExceeded::Steps { maximum: 3 })
406 );
407 }
408
409 #[test]
410 fn recursion_depth_is_capped_and_released() {
411 let mut budget = Budget::start(Limits {
412 max_recursion_depth: 2,
413 ..Limits::default()
414 });
415 assert!(budget.enter_call().is_ok());
416 assert!(budget.enter_call().is_ok());
417 assert_eq!(
418 budget.enter_call(),
419 Err(LimitExceeded::RecursionDepth { maximum: 2 })
420 );
421 budget.leave_call();
422 assert!(budget.enter_call().is_ok());
423 }
424
425 #[test]
426 fn capability_calls_are_counted_separately_from_steps() {
427 let mut budget = Budget::start(Limits {
428 max_capability_calls: 1,
429 ..Limits::default()
430 });
431 assert!(budget.charge_capability_call().is_ok());
432 assert_eq!(
433 budget.charge_capability_call(),
434 Err(LimitExceeded::CapabilityCalls { maximum: 1 })
435 );
436 assert_eq!(budget.steps(), 0);
437 assert_eq!(budget.capability_calls(), 1);
438 }
439
440 #[test]
441 fn value_bytes_accumulate_across_the_whole_run() {
442 let mut budget = Budget::start(Limits {
443 max_value_bytes: 10,
444 ..Limits::default()
445 });
446 assert!(budget.charge_value_bytes(6).is_ok());
447 assert_eq!(
449 budget.charge_value_bytes(6),
450 Err(LimitExceeded::ValueBytes { maximum: 10 })
451 );
452 assert_eq!(budget.value_bytes(), 12);
453 }
454
455 #[test]
456 fn an_expired_deadline_is_reported_immediately() {
457 let budget = Budget::start(Limits {
458 timeout: Duration::ZERO,
459 ..Limits::default()
460 });
461 std::thread::sleep(Duration::from_millis(2));
462 assert!(matches!(
463 budget.check_deadline(),
464 Err(LimitExceeded::Deadline { .. })
465 ));
466 assert_eq!(budget.remaining(), Duration::ZERO);
467 }
468
469 #[test]
470 fn charging_a_step_re_reads_the_deadline() {
471 let mut budget = Budget::start(Limits {
474 max_steps: u64::MAX,
475 timeout: Duration::from_millis(5),
476 ..Limits::default()
477 });
478 assert!(budget.charge_step().is_ok());
479 std::thread::sleep(Duration::from_millis(10));
480 assert!(matches!(
481 budget.charge_step(),
482 Err(LimitExceeded::Deadline { .. })
483 ));
484 assert_eq!(budget.steps(), 2);
485 }
486
487 #[test]
488 fn output_under_both_ceilings_is_preserved_exactly() {
489 let mut buffer = OutputBuffer::new(&Limits::default());
490 buffer.push_line("first");
491 buffer.push_block("second\nthird");
492 assert!(!buffer.is_truncated());
493 assert_eq!(buffer.render(), "first\nsecond\nthird");
494 }
495
496 #[test]
497 fn the_line_ceiling_keeps_head_and_tail() {
498 let mut buffer = OutputBuffer::new(&Limits {
499 max_output_lines: 4,
500 ..Limits::default()
501 });
502 for index in 0..20 {
503 buffer.push_line(&format!("line-{index}"));
504 }
505 let rendered = buffer.render();
506 assert!(buffer.is_truncated());
507 assert!(rendered.starts_with("line-0\nline-1\n"), "{rendered}");
508 assert!(rendered.ends_with("line-18\nline-19"), "{rendered}");
509 assert!(
510 rendered.contains("... Output truncated (20 total lines) ..."),
511 "{rendered}"
512 );
513 }
514
515 #[test]
516 fn one_oversized_line_cannot_bypass_the_byte_ceiling() {
517 let mut buffer = OutputBuffer::new(&Limits {
518 max_output_bytes: 32,
519 max_output_lines: 10_000,
520 ..Limits::default()
521 });
522 buffer.push_line(&"x".repeat(4096));
523 buffer.push_line("tail");
524 assert!(buffer.is_truncated());
525 assert!(buffer.render().len() < 200, "{}", buffer.render());
526 assert!(buffer.render().ends_with("tail"));
527 }
528}