lua-rs-runtime 0.0.18

Embed Lua 5.4 in Rust: handles, userdata, and scoped borrows. Pure safe Rust, no C, runs in WebAssembly.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! Exploratory sandbox behavior tests.
//!
//! Proves the three sandbox controls — instruction budget, memory ceiling,
//! and capability stripping — actually bound untrusted code, and that a
//! non-sandboxed run is unaffected.

use lua_rs_runtime::{Lua, SandboxConfig, TripReason};

/// A tight infinite loop must be aborted by the instruction budget rather
/// than hanging the process.
#[test]
fn infinite_loop_is_aborted() {
    let config = SandboxConfig {
        instruction_limit: Some(200_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua.load("while true do end").exec();

    assert!(result.is_err(), "infinite loop should be aborted");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
    assert_eq!(sandbox.instructions_remaining(), Some(0));
}

/// A recursive infinite loop (exercises call dispatch, not just JMP) is also
/// bounded.
#[test]
fn runaway_recursion_is_aborted() {
    let config = SandboxConfig {
        instruction_limit: Some(500_000),
        memory_limit_bytes: None,
        check_interval: 512,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load("local function f() return 1 + (function() while true do end end)() end f()")
        .exec();

    assert!(result.is_err());
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// Work that finishes inside the budget runs normally and does not trip.
#[test]
fn work_within_budget_completes() {
    let config = SandboxConfig {
        instruction_limit: Some(10_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load("local s = 0 for i = 1, 100000 do s = s + i end assert(s == 5000050000)")
        .exec();

    assert!(result.is_ok(), "in-budget work should run: {result:?}");
    assert_eq!(sandbox.tripped(), None);
    assert!(sandbox.instructions_used().unwrap() > 0);
}

/// A memory bomb (unbounded allocation) trips the memory ceiling.
#[test]
fn memory_bomb_is_aborted() {
    let config = SandboxConfig {
        instruction_limit: None,
        memory_limit_bytes: Some(8 * 1024 * 1024),
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load("local t = {} local i = 0 while true do i = i + 1 t[i] = string.rep('x', 1024) end")
        .exec();

    assert!(result.is_err(), "memory bomb should be aborted");
    assert_eq!(sandbox.tripped(), Some(TripReason::Memory));
}

/// The strict preset removes host-access and code-loading globals while
/// leaving pure libraries intact.
#[test]
fn strict_preset_strips_capabilities() {
    let (lua, _sandbox) = Lua::sandboxed(SandboxConfig::strict()).unwrap();

    let result = lua
        .load(
            r#"
            assert(os.execute == nil, "os.execute should be removed")
            assert(os.exit == nil, "os.exit should be removed")
            assert(io == nil, "io should be removed")
            assert(load == nil, "load should be removed")
            assert(dofile == nil, "dofile should be removed")
            assert(require == nil, "require should be removed")
            assert(package == nil, "package should be removed")
            assert(debug == nil, "debug should be removed")
            -- pure libraries remain
            assert(string.rep ~= nil, "string should remain")
            assert(math.sqrt ~= nil, "math should remain")
            assert(table.insert ~= nil, "table should remain")
            assert(os.time ~= nil, "os.time should remain")
            assert(tostring ~= nil, "tostring should remain")
        "#,
        )
        .exec();

    assert!(result.is_ok(), "capability assertions failed: {result:?}");
}

/// After a trip, `reset()` refills the budget so the same state can run more
/// code.
#[test]
fn reset_refills_budget() {
    let config = SandboxConfig {
        instruction_limit: Some(50_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    assert!(lua.load("while true do end").exec().is_err());
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));

    sandbox.reset();
    assert_eq!(sandbox.tripped(), None);
    assert_eq!(sandbox.instructions_remaining(), Some(50_000));

    let result = lua.load("assert(1 + 1 == 2)").exec();
    assert!(result.is_ok(), "post-reset run should succeed: {result:?}");
}

/// The budget follows code running inside a coroutine — the escape that the
/// GlobalState-backed design exists to close. Without metering spanning threads
/// this hangs forever.
#[test]
fn coroutine_is_metered() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(300_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load("local co = coroutine.wrap(function() while true do end end) co()")
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "coroutine ran unmetered -> budget escaped"
    );
    assert!(result.is_err(), "coroutine infinite loop should be aborted");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// A coroutine that yields and resumes normally still runs to completion when
/// it stays within budget.
#[test]
fn yielding_coroutine_within_budget_completes() {
    let config = SandboxConfig {
        instruction_limit: Some(10_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load(
            r#"
            local co = coroutine.wrap(function()
                local s = 0
                for i = 1, 1000 do s = s + i coroutine.yield(s) end
                return s
            end)
            local last = 0
            for _ = 1, 1000 do last = co() end
            assert(last == 500500)
        "#,
        )
        .exec();
    assert!(result.is_ok(), "in-budget coroutine should run: {result:?}");
    assert_eq!(sandbox.tripped(), None);
}

/// The budget trip is uncatchable: a `pcall` loop cannot keep runaway code
/// alive. Without re-raising, this runs forever.
#[test]
fn pcall_loop_cannot_escape() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(300_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load("while true do pcall(function() while true do end end) end")
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "pcall loop escaped the budget"
    );
    assert!(result.is_err(), "pcall loop should abort, not run forever");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// A single `pcall` around a runaway cannot swallow the trip: the chunk must
/// error out, not return normally.
#[test]
fn single_pcall_cannot_swallow_trip() {
    let config = SandboxConfig {
        instruction_limit: Some(300_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load("local ok = pcall(function() while true do end end) return ok")
        .exec();
    assert!(result.is_err(), "pcall must not swallow the budget trip");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// `xpcall`'s message handler cannot run on (and thus cannot loop on or
/// swallow) a budget trip.
#[test]
fn xpcall_cannot_swallow_trip() {
    let config = SandboxConfig {
        instruction_limit: Some(300_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load(
            "local ok = xpcall(function() while true do end end, function() return 'handled' end) return ok",
        )
        .exec();
    assert!(result.is_err(), "xpcall must not swallow the budget trip");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// Resuming a runaway coroutine in a loop cannot keep it alive — `resume`
/// re-raises the trip rather than returning `false, msg`.
#[test]
fn resume_loop_cannot_escape() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(300_000),
        memory_limit_bytes: None,
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load(
            r#"
            while true do
                local co = coroutine.create(function() while true do end end)
                coroutine.resume(co)
            end
        "#,
        )
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "resume loop escaped the budget"
    );
    assert!(result.is_err());
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// `pcall` still works normally (catches ordinary errors) when no sandbox is
/// active — the re-raise is gated on an in-flight abort.
#[test]
fn pcall_still_catches_ordinary_errors_under_sandbox() {
    let config = SandboxConfig {
        instruction_limit: Some(10_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load(
            r#"
            local ok, msg = pcall(function() error("boom") end)
            assert(ok == false, "pcall should catch ordinary errors")
            assert(tostring(msg):find("boom"), "message should propagate")
        "#,
        )
        .exec();
    assert!(result.is_ok(), "ordinary pcall must still work: {result:?}");
    assert_eq!(sandbox.tripped(), None);
}

/// A single huge `string.rep` allocation is refused *before* it is built,
/// rather than overshooting the ceiling (the stdlib self-guard is 2 GiB, far
/// above a typical sandbox cap).
#[test]
fn huge_string_rep_aborts_at_cap() {
    let config = SandboxConfig {
        instruction_limit: None,
        memory_limit_bytes: Some(32 * 1024 * 1024),
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load("return ('x'):rep(256 * 1024 * 1024)")
        .exec();
    assert!(result.is_err(), "256 MiB rep under a 32 MiB cap must abort");
    assert_eq!(sandbox.tripped(), Some(TripReason::Memory));
}

/// The memory cap is uncatchable too: a `pcall` loop allocating big strings
/// cannot keep running.
#[test]
fn memory_cap_is_uncatchable() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: None,
        memory_limit_bytes: Some(32 * 1024 * 1024),
        check_interval: 256,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load("while true do pcall(function() return ('x'):rep(64 * 1024 * 1024) end) end")
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "memory cap escaped via pcall loop"
    );
    assert!(result.is_err());
    assert_eq!(sandbox.tripped(), Some(TripReason::Memory));
}

/// A catastrophic-backtracking pattern match — one stdlib C call that the
/// per-instruction budget cannot preempt — is now bounded by charging the
/// matcher's work against the instruction budget.
#[test]
fn catastrophic_pattern_is_bounded() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(2_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    // Classic exponential Lua-pattern backtracking: N optional `a?` followed by
    // N required `a`, matched against N `a` — 2^N backtracks without a budget.
    let result = lua
        .load(
            r#"
            local s = ("a"):rep(28)
            local p = ("a?"):rep(28) .. ("a"):rep(28)
            return s:match(p)
        "#,
        )
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "catastrophic pattern hung"
    );
    assert!(result.is_err(), "runaway match should abort");
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// `string.gsub` with a runaway pattern is bounded too, and the abort is
/// uncatchable (a `pcall` loop around it cannot keep it alive).
#[test]
fn catastrophic_gsub_is_uncatchable() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(2_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load(
            r#"
            local p = ("a?"):rep(28) .. ("a"):rep(28)
            while true do pcall(function() (("a"):rep(28)):gsub(p, "x") end) end
        "#,
        )
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "gsub pcall loop escaped"
    );
    assert!(result.is_err());
    assert_eq!(sandbox.tripped(), Some(TripReason::Instructions));
}

/// Ordinary pattern matching still works correctly under a sandbox (the matcher
/// instrumentation must not change results, only bound runaway work).
#[test]
fn ordinary_pattern_matching_still_works() {
    let config = SandboxConfig {
        instruction_limit: Some(10_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let result = lua
        .load(
            r#"
            assert(("hello world"):match("(%w+) (%w+)") == "hello")
            assert(("a,b,c"):gsub(",", ";") == "a;b;c")
            local n = 0
            for _ in ("1 22 333"):gmatch("%d+") do n = n + 1 end
            assert(n == 3, "gmatch count")
        "#,
        )
        .exec();
    assert!(result.is_ok(), "ordinary matching broke: {result:?}");
    assert_eq!(sandbox.tripped(), None);
}

/// `table.sort` with an adversarial comparator cannot run unbounded: each
/// comparison is a metered Lua call, so the instruction budget bounds it.
#[test]
fn adversarial_sort_is_bounded() {
    use std::time::{Duration, Instant};
    let config = SandboxConfig {
        instruction_limit: Some(2_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    };
    let (lua, sandbox) = Lua::sandboxed(config).unwrap();

    let start = Instant::now();
    let result = lua
        .load(
            r#"
            local t = {}
            for i = 1, 5000 do t[i] = i end
            -- inconsistent comparator: forces pathological comparison counts
            table.sort(t, function(a, b) return true end)
        "#,
        )
        .exec();
    assert!(
        start.elapsed() < Duration::from_secs(5),
        "adversarial sort hung"
    );
    assert!(result.is_err());
}

/// Deep non-tail recursion errors cleanly via the call-depth guard rather than
/// overflowing the host (Rust) stack and crashing the process.
#[test]
fn recursion_deep_nontail_errors_cleanly() {
    let (lua, _s) = Lua::sandboxed(SandboxConfig {
        instruction_limit: Some(50_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    })
    .unwrap();
    let result = lua.load("local function f(n) return 1 + f(n + 1) end f(0)").exec();
    assert!(result.is_err(), "deep recursion must error, not crash");
}

/// Infinite metamethod nesting (`__index` that re-indexes) errors cleanly via
/// the C-call depth guard.
#[test]
fn recursion_infinite_metamethod_errors_cleanly() {
    let (lua, _s) = Lua::sandboxed(SandboxConfig {
        instruction_limit: Some(50_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    })
    .unwrap();
    let result = lua
        .load(
            r#"
            local t = setmetatable({}, {__index = function(tbl, k) return tbl[k] end})
            return t.x
        "#,
        )
        .exec();
    assert!(result.is_err(), "infinite metamethod recursion must error, not crash");
}

/// A nested-coroutine `__close` cascade (the historically stack-overflow-prone
/// case) errors cleanly rather than crashing the host.
#[test]
fn recursion_coroutine_close_cascade_errors_cleanly() {
    let (lua, _s) = Lua::sandboxed(SandboxConfig {
        instruction_limit: Some(50_000_000),
        memory_limit_bytes: None,
        check_interval: 1000,
        remove_globals: Vec::new(),
    })
    .unwrap();
    let result = lua
        .load(
            r#"
            local function nest(n)
                if n == 0 then return end
                local x <close> = setmetatable({}, {__close = function() end})
                local co = coroutine.wrap(function() nest(n - 1) coroutine.yield() end)
                co()
            end
            nest(3000)
        "#,
        )
        .exec();
    assert!(result.is_err(), "close cascade must error, not crash");
}

/// A plain (non-sandboxed) runtime is unaffected: no hook, no stripping.
#[test]
fn plain_runtime_is_unbounded() {
    let lua = Lua::new();
    let result = lua
        .load("local s = 0 for i = 1, 1000000 do s = s + 1 end assert(s == 1000000)")
        .exec();
    assert!(result.is_ok(), "plain runtime should run freely: {result:?}");
}