1use std::time::Duration;
4
5use thiserror::Error;
6
7#[derive(Debug, Clone, PartialEq, Eq, Error)]
15pub enum SandboxError {
16 #[error(
18 "rule execution exceeded its {budget:?} budget\n \
19 the handler was still running after {budget:?} and was stopped\n \
20 a rule that cannot finish in that time usually needs a tighter query, not a \
21 longer clock — raise it with `timeout` on the rule if the work is genuinely heavy"
22 )]
23 RuleTimeout {
24 budget: Duration,
26 },
27
28 #[error(
30 "the run exceeded its {budget:?} budget after {elapsed:?}\n \
31 no single rule necessarily misbehaved — the total simply ran too long\n \
32 raise it with `--timeout`, or narrow what is being checked"
33 )]
34 RunTimeout {
35 budget: Duration,
37 elapsed: Duration,
39 },
40
41 #[error(
43 "rule execution exceeded its {limit_bytes} byte memory ceiling\n \
44 this usually means a rule accumulated without bound — check for a loop that \
45 collects into an array or map that is never cleared"
46 )]
47 MemoryExceeded {
48 limit_bytes: usize,
50 },
51
52 #[error("rule threw: {message}{}", crate::error::indent_stack(.stack.as_deref()))]
54 Script {
55 message: String,
57 stack: Option<String>,
59 },
60
61 #[error(
63 "rule threw a non-Error value\n \
64 throwing a string or object loses the stack trace; throw an Error instead"
65 )]
66 NonErrorThrown,
67
68 #[error("javascript engine error: {0}")]
70 Engine(String),
71}
72
73impl SandboxError {
74 #[must_use]
79 pub const fn is_limit_breach(&self) -> bool {
80 matches!(
81 self,
82 Self::RuleTimeout { .. } | Self::RunTimeout { .. } | Self::MemoryExceeded { .. }
83 )
84 }
85}
86
87fn indent_stack(stack: Option<&str>) -> String {
88 use std::fmt::Write as _;
89
90 match stack {
91 Some(s) if !s.trim().is_empty() => s.lines().fold(String::new(), |mut out, line| {
92 let _ = write!(out, "\n {}", line.trim_end());
95 out
96 }),
97 _ => String::new(),
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn limit_breaches_are_distinguishable_from_rule_bugs() {
107 assert!(
108 SandboxError::RuleTimeout {
109 budget: Duration::from_secs(1)
110 }
111 .is_limit_breach()
112 );
113 assert!(
114 SandboxError::RunTimeout {
115 budget: Duration::from_secs(15),
116 elapsed: Duration::from_secs(16),
117 }
118 .is_limit_breach()
119 );
120 assert!(SandboxError::MemoryExceeded { limit_bytes: 1024 }.is_limit_breach());
121
122 assert!(
123 !SandboxError::Script {
124 message: "boom".to_owned(),
125 stack: None
126 }
127 .is_limit_breach()
128 );
129 assert!(!SandboxError::NonErrorThrown.is_limit_breach());
130 assert!(!SandboxError::Engine("odd".to_owned()).is_limit_breach());
131 }
132
133 #[test]
134 fn the_rule_timeout_message_suggests_the_usual_fix() {
135 let rendered = SandboxError::RuleTimeout {
138 budget: Duration::from_secs(1),
139 }
140 .to_string();
141 assert!(rendered.contains("tighter query"), "{rendered}");
142 assert!(rendered.contains("timeout"), "{rendered}");
143 }
144
145 #[test]
146 fn the_run_timeout_message_does_not_blame_a_rule() {
147 let rendered = SandboxError::RunTimeout {
151 budget: Duration::from_secs(15),
152 elapsed: Duration::from_secs(16),
153 }
154 .to_string();
155 assert!(rendered.contains("no single rule"), "{rendered}");
156 }
157
158 #[test]
159 fn a_script_error_renders_its_stack_indented() {
160 let rendered = SandboxError::Script {
161 message: "cannot read x".to_owned(),
162 stack: Some("at check (rule.ts:4:2)\nat <eval> (rule.ts:1:1)".to_owned()),
163 }
164 .to_string();
165
166 assert!(
167 rendered.starts_with("rule threw: cannot read x"),
168 "{rendered}"
169 );
170 assert!(
171 rendered.contains("\n at check (rule.ts:4:2)"),
172 "{rendered}"
173 );
174 }
175
176 #[test]
177 fn a_script_error_without_a_stack_renders_cleanly() {
178 let rendered = SandboxError::Script {
179 message: "boom".to_owned(),
180 stack: None,
181 }
182 .to_string();
183 assert_eq!(rendered, "rule threw: boom");
184
185 let blank = SandboxError::Script {
186 message: "boom".to_owned(),
187 stack: Some(" \n".to_owned()),
188 }
189 .to_string();
190 assert_eq!(
191 blank, "rule threw: boom",
192 "whitespace-only stack should not add lines"
193 );
194 }
195}