Skip to main content

luft_runtime/
error.rs

1//! Script execution errors (code-design §4.7).
2
3use thiserror::Error;
4
5/// Execution limits for a Lua script (instruction count, wall clock, memory).
6#[derive(Debug, Clone)]
7pub struct ExecLimits {
8    /// Maximum Lua VM instruction count (0 = unlimited).
9    pub instruction_limit: u64,
10    /// Maximum wall-clock time (None = unlimited).
11    pub wall_clock_ms: Option<u64>,
12    /// Maximum heap size in bytes (0 = unlimited).
13    pub memory_limit_bytes: u64,
14}
15
16impl Default for ExecLimits {
17    fn default() -> Self {
18        Self {
19            instruction_limit: 1_000_000,
20            wall_clock_ms: Some(300_000),          // 5 minutes
21            memory_limit_bytes: 128 * 1024 * 1024, // 128 MB
22        }
23    }
24}
25
26/// Errors that can occur during script execution.
27#[derive(Error, Debug)]
28pub enum ScriptError {
29    #[error("syntax error: {0}")]
30    Syntax(String),
31
32    #[error("sandbox violation: attempted to access forbidden global `{name}`")]
33    SandboxViolation { name: String },
34
35    #[error("instruction limit exceeded (limit: {0})")]
36    InstructionLimitExceeded(u64),
37
38    #[error("wall-clock timeout ({0}ms)")]
39    WallClockTimeout(u64),
40
41    #[error("memory limit exceeded ({0} bytes)")]
42    MemoryLimitExceeded(u64),
43
44    #[error("agent error: {0}")]
45    AgentError(String),
46
47    #[error("serialization error: {0}")]
48    SerdeError(String),
49
50    #[error("internal error: {0}")]
51    Internal(String),
52
53    #[error("script is missing a `function main() ... end` entry point")]
54    MissingMain,
55}
56
57impl From<mlua::Error> for ScriptError {
58    fn from(e: mlua::Error) -> Self {
59        use mlua::Error::*;
60        match e {
61            SyntaxError { message, .. } => ScriptError::Syntax(message),
62            RuntimeError(msg) => {
63                // Try to detect sandbox violations from error messages
64                if msg.contains("forbidden") || msg.contains("not allowed") {
65                    ScriptError::SandboxViolation { name: msg.clone() }
66                } else if msg.contains("instruction limit") {
67                    ScriptError::InstructionLimitExceeded(0)
68                } else if msg.contains("timeout") {
69                    ScriptError::WallClockTimeout(0)
70                } else {
71                    ScriptError::AgentError(msg)
72                }
73            }
74            _ => ScriptError::Internal(e.to_string()),
75        }
76    }
77}
78
79impl From<serde_json::Error> for ScriptError {
80    fn from(e: serde_json::Error) -> Self {
81        ScriptError::SerdeError(e.to_string())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    // -----------------------------------------------------------------------
90    // ExecLimits
91    // -----------------------------------------------------------------------
92    #[test]
93    fn exec_limits_default() {
94        let limits = ExecLimits::default();
95        assert_eq!(limits.instruction_limit, 1_000_000);
96        assert_eq!(limits.wall_clock_ms, Some(300_000));
97        assert_eq!(limits.memory_limit_bytes, 128 * 1024 * 1024);
98    }
99
100    #[test]
101    fn exec_limits_debug_and_clone() {
102        let a = ExecLimits::default();
103        let b = a.clone();
104        assert_eq!(a.instruction_limit, b.instruction_limit);
105        let _ = format!("{:?}", a); // Debug
106    }
107
108    // -----------------------------------------------------------------------
109    // ScriptError – Display formatting for every variant
110    // -----------------------------------------------------------------------
111    #[test]
112    fn display_syntax() {
113        let err = ScriptError::Syntax("unexpected symbol".into());
114        assert_eq!(err.to_string(), "syntax error: unexpected symbol");
115    }
116
117    #[test]
118    fn display_sandbox_violation() {
119        let err = ScriptError::SandboxViolation { name: "os".into() };
120        assert_eq!(
121            err.to_string(),
122            "sandbox violation: attempted to access forbidden global `os`"
123        );
124    }
125
126    #[test]
127    fn display_instruction_limit() {
128        let err = ScriptError::InstructionLimitExceeded(50_000);
129        assert_eq!(err.to_string(), "instruction limit exceeded (limit: 50000)");
130    }
131
132    #[test]
133    fn display_wall_clock_timeout() {
134        let err = ScriptError::WallClockTimeout(300_000);
135        assert_eq!(err.to_string(), "wall-clock timeout (300000ms)");
136    }
137
138    #[test]
139    fn display_memory_limit() {
140        let err = ScriptError::MemoryLimitExceeded(134_217_728);
141        assert_eq!(err.to_string(), "memory limit exceeded (134217728 bytes)");
142    }
143
144    #[test]
145    fn display_agent_error() {
146        let err = ScriptError::AgentError("something went wrong".into());
147        assert_eq!(err.to_string(), "agent error: something went wrong");
148    }
149
150    #[test]
151    fn display_serde_error() {
152        let err = ScriptError::SerdeError("invalid type".into());
153        assert_eq!(err.to_string(), "serialization error: invalid type");
154    }
155
156    #[test]
157    fn display_internal() {
158        let err = ScriptError::Internal("unexpected state".into());
159        assert_eq!(err.to_string(), "internal error: unexpected state");
160    }
161
162    // -----------------------------------------------------------------------
163    // ScriptError – Debug derives (smoke-check that all variants are Debug)
164    // -----------------------------------------------------------------------
165    #[test]
166    fn debug_format() {
167        let variants: Vec<ScriptError> = vec![
168            ScriptError::Syntax("x".into()),
169            ScriptError::SandboxViolation { name: "x".into() },
170            ScriptError::InstructionLimitExceeded(1),
171            ScriptError::WallClockTimeout(1),
172            ScriptError::MemoryLimitExceeded(1),
173            ScriptError::AgentError("x".into()),
174            ScriptError::SerdeError("x".into()),
175            ScriptError::Internal("x".into()),
176        ];
177        for v in &variants {
178            let _ = format!("{:?}", v);
179        }
180    }
181
182    // -----------------------------------------------------------------------
183    // From<mlua::Error>
184    // -----------------------------------------------------------------------
185    #[test]
186    fn from_mlua_syntax_error() {
187        let e = mlua::Error::SyntaxError {
188            message: "unexpected symbol near ')'".into(),
189            incomplete_input: false,
190        };
191        match ScriptError::from(e) {
192            ScriptError::Syntax(msg) => assert_eq!(msg, "unexpected symbol near ')'"),
193            other => panic!("expected Syntax, got {other:?}"),
194        }
195    }
196
197    #[test]
198    fn from_mlua_runtime_forbidden() {
199        let e = mlua::Error::RuntimeError("forbidden global 'os'".into());
200        match ScriptError::from(e) {
201            ScriptError::SandboxViolation { name } => {
202                assert_eq!(name, "forbidden global 'os'");
203            }
204            other => panic!("expected SandboxViolation, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn from_mlua_runtime_not_allowed() {
210        let e = mlua::Error::RuntimeError("this function is not allowed".into());
211        match ScriptError::from(e) {
212            ScriptError::SandboxViolation { name } => {
213                assert_eq!(name, "this function is not allowed");
214            }
215            other => panic!("expected SandboxViolation, got {other:?}"),
216        }
217    }
218
219    #[test]
220    fn from_mlua_runtime_instruction_limit() {
221        let e = mlua::Error::RuntimeError("instruction limit exceeded".into());
222        match ScriptError::from(e) {
223            ScriptError::InstructionLimitExceeded(limit) => assert_eq!(limit, 0),
224            other => panic!("expected InstructionLimitExceeded, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn from_mlua_runtime_timeout() {
230        let e = mlua::Error::RuntimeError("timeout reached".into());
231        match ScriptError::from(e) {
232            ScriptError::WallClockTimeout(ms) => assert_eq!(ms, 0),
233            other => panic!("expected WallClockTimeout, got {other:?}"),
234        }
235    }
236
237    #[test]
238    fn from_mlua_runtime_generic_agent_error() {
239        let e = mlua::Error::RuntimeError("some random Lua error".into());
240        match ScriptError::from(e) {
241            ScriptError::AgentError(msg) => assert_eq!(msg, "some random Lua error"),
242            other => panic!("expected AgentError, got {other:?}"),
243        }
244    }
245
246    #[test]
247    fn from_mlua_non_runtime_internal() {
248        // MemoryError is not SyntaxError or RuntimeError, so it hits the catch-all
249        let e = mlua::Error::MemoryError("OOM".into());
250        match ScriptError::from(e) {
251            ScriptError::Internal(msg) => assert!(msg.contains("memory error")),
252            other => panic!("expected Internal, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn from_mlua_safety_error_internal() {
258        let e = mlua::Error::SafetyError("unsafe operation".into());
259        match ScriptError::from(e) {
260            ScriptError::Internal(msg) => assert!(msg.contains("safety error")),
261            other => panic!("expected Internal, got {other:?}"),
262        }
263    }
264
265    // -----------------------------------------------------------------------
266    // From<serde_json::Error>
267    // -----------------------------------------------------------------------
268    #[test]
269    fn from_serde_json_error() {
270        let invalid: serde_json::Error = serde_json::from_str::<()>("!").unwrap_err();
271        match ScriptError::from(invalid) {
272            ScriptError::SerdeError(msg) => assert!(!msg.is_empty()),
273            other => panic!("expected SerdeError, got {other:?}"),
274        }
275    }
276}