// testSandbox.c -- per-context resource limits (calogContextOpenLimited). Verifies the allow-list // (a script may call only permitted natives, engine-agnostically), the memory cap (an over-budget // script is retired instead of exhausting the host), and the wall-clock budget (a runaway loop is // retired) -- on Lua, JavaScript, AND my-basic, which the fork brought to parity (design.md sec 23). // The other engines (Squirrel/Berry/s7/Wren/mruby/Tcl/Janet) still get the allow-list only. #define _POSIX_C_SOURCE 200809L #include "calog.h" #include #include #include #include #define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__) #define PUMP_LIMIT 6000 static CalogT *calog = NULL; static _Atomic bool reachedFlag = false; static _Atomic bool forbiddenFlag = false; static _Atomic int32_t reportValue = -1; static _Atomic int32_t errorCount = 0; static _Atomic bool doneFlag = false; static int32_t testsRun = 0; static int32_t testsFailed = 0; static void checkImpl(bool condition, const char *message, int32_t line); static int32_t nativeDone(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeForbidden(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeReached(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static void onError(uint64_t contextId, const char *message, void *userData); static void resetFlags(void); static void runLimited(const CalogEngineT *engine, const char *source, const CalogLimitsT *limits); static void checkImpl(bool condition, const char *message, int32_t line) { testsRun++; if (!condition) { testsFailed++; printf("FAIL testSandbox.c:%d %s\n", line, message); } } static int32_t nativeDone(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; atomic_store(&doneFlag, true); calogValueNil(result); return calogOkE; } static int32_t nativeForbidden(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; atomic_store(&forbiddenFlag, true); calogValueNil(result); return calogOkE; } static int32_t nativeReached(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; atomic_store(&reachedFlag, true); calogValueNil(result); return calogOkE; } static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount == 1 && args[0].type == calogBoolE) { atomic_store(&reportValue, args[0].as.b ? 1 : 0); } return calogOkE; } static void onError(uint64_t contextId, const char *message, void *userData) { (void)contextId; (void)message; (void)userData; atomic_fetch_add(&errorCount, 1); atomic_store(&doneFlag, true); } static void resetFlags(void) { atomic_store(&reachedFlag, false); atomic_store(&forbiddenFlag, false); atomic_store(&reportValue, -1); atomic_store(&errorCount, 0); atomic_store(&doneFlag, false); } // Open a limited context, run the script fire-and-forget, and pump until it signals done() or its // error reaches the handler (or a bounded timeout). static void runLimited(const CalogEngineT *engine, const char *source, const CalogLimitsT *limits) { CalogContextT *ctx; struct timespec ts = { 0, 500000 }; int32_t i; ctx = calogContextOpenLimited(calog, engine, limits); if (ctx == NULL) { checkImpl(false, "context open failed", __LINE__); return; } calogContextEval(ctx, source); for (i = 0; i < PUMP_LIMIT; i++) { calogPump(calog); if (atomic_load(&doneFlag)) { calogPump(calog); break; } nanosleep(&ts, NULL); } calogContextClose(ctx); } int main(void) { static const char *const allowList[] = { "reached", "report", "done", NULL }; CalogLimitsT allowLimits; CalogLimitsT memLimits; CalogLimitsT timeLimits; calog = calogCreate(); if (calog == NULL) { printf("calog create failed\n"); return 1; } calogSetErrorHandler(calog, onError, NULL); calogRegister(calog, "reached", nativeReached, NULL); calogRegister(calog, "forbidden", nativeForbidden, NULL); calogRegister(calog, "report", nativeReport, NULL); calogRegisterInline(calog, "done", nativeDone, NULL); // 1. Allow-list: the script may call reached/report/done, but forbidden is denied (pcall catches // the "not permitted" error), so its native body never runs. memset(&allowLimits, 0, sizeof(allowLimits)); allowLimits.allowList = allowList; resetFlags(); runLimited(&calogLuaEngine, "reached(); local ok = pcall(forbidden); report(ok); done()", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list: a permitted native runs"); CHECK(atomic_load(&reportValue) == 0, "allow-list: calling a forbidden native errors (pcall -> false)"); CHECK(!atomic_load(&forbiddenFlag), "allow-list: the forbidden native body never ran"); // 2. Memory cap (Lua): a 2 MiB context cannot allocate a 10 MiB string, so it errors before // reached(); an unlimited context runs the same allocation fine. memset(&memLimits, 0, sizeof(memLimits)); memLimits.memoryBytes = 2 * 1024 * 1024; resetFlags(); runLimited(&calogLuaEngine, "local s = string.rep('x', 10 * 1024 * 1024); reached(); done()", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap: over-budget allocation errors"); CHECK(!atomic_load(&reachedFlag), "memory cap: the script did not continue past the failed allocation"); resetFlags(); runLimited(&calogLuaEngine, "local s = string.rep('x', 10 * 1024 * 1024); reached(); done()", NULL); CHECK(atomic_load(&reachedFlag), "no memory cap: the same allocation succeeds"); // 3. Wall-clock budget: a runaway loop is retired (its error reaches the handler) within the // bounded pump, on Lua and on JavaScript. memset(&timeLimits, 0, sizeof(timeLimits)); timeLimits.wallClockMillis = 100; resetFlags(); runLimited(&calogLuaEngine, "while true do end", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (Lua): a runaway loop is retired"); resetFlags(); runLimited(&calogJsEngine, "while (true) {}", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (JS): a runaway loop is retired"); // 4. my-basic parity: the fork owns its VM, so it gets the same three limits as Lua/JS -- the // allow-list (already engine-agnostic), the wall-clock budget (a per-statement step hook), and the // memory cap (a counting allocator enforced at the statement boundary). resetFlags(); runLimited(&calogMyBasicEngine, "reached()\nforbidden()\ndone()", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (my-basic): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (my-basic): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (my-basic): the forbidden native body never ran"); resetFlags(); runLimited(&calogMyBasicEngine, "x = 0\nWHILE 1\nx = x + 1\nWEND", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (my-basic): a runaway loop is retired"); // Doubling a string blows the 2 MiB budget within ~21 iterations; the step hook retires it. resetFlags(); runLimited(&calogMyBasicEngine, "s = \"x\"\nWHILE 1\ns = s + s\nWEND", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (my-basic): an over-budget script is retired"); // 5. Berry parity: the allow-list (engine-agnostic), the wall-clock budget (the VM's // instruction-loop heartbeat hook), and the memory cap (the be_realloc cap patch, which // refuses a grow past budget with a clean be_throw). resetFlags(); runLimited(&calogBerryEngine, "reached()\nforbidden()\ndone()", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (berry): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (berry): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (berry): the forbidden native body never ran"); resetFlags(); runLimited(&calogBerryEngine, "x = 0\nwhile true\nx = x + 1\nend", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (berry): a runaway loop is retired"); resetFlags(); runLimited(&calogBerryEngine, "s = 'x'\nwhile true\ns = s + s\nend", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (berry): an over-budget script is retired"); // 6. Tcl parity: the allow-list (engine-agnostic), the wall-clock budget (Tcl's built-in // per-interp time limit, checked from the bytecode loop), and the memory cap (the zippy-allocator // counting patch that trips an async handler once over budget). resetFlags(); runLimited(&calogTclEngine, "reached\nforbidden\ndone", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (tcl): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (tcl): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (tcl): the forbidden native body never ran"); resetFlags(); runLimited(&calogTclEngine, "while {1} {}", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (tcl): a runaway loop is retired"); resetFlags(); runLimited(&calogTclEngine, "set s x\nwhile {1} {append s $s}", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (tcl): an over-budget script is retired"); // 7. mruby parity: the allow-list (engine-agnostic), the wall-clock budget (the per-instruction // code-fetch hook, compiled in via MRB_USE_DEBUG_HOOK), and the memory cap (the mrb_basic_alloc_func // override, which refuses a grow past budget for a clean NoMemoryError). resetFlags(); runLimited(&calogMrubyEngine, "reached()\nforbidden()\ndone()", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (mruby): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (mruby): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (mruby): the forbidden native body never ran"); resetFlags(); runLimited(&calogMrubyEngine, "while true do end", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (mruby): a runaway loop is retired"); resetFlags(); runLimited(&calogMrubyEngine, "s = 'x'\nwhile true do\ns = s + s\nend", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (mruby): an over-budget script is retired"); // 8. Squirrel parity: the allow-list (engine-agnostic), the wall-clock budget and the memory cap // (both enforced from the VM's Execute dispatch loop -- the counting allocator trips a flag the // loop checks every instruction, and a periodic poll checks the deadline). resetFlags(); runLimited(&calogSquirrelEngine, "reached(); forbidden(); done();", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (squirrel): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (squirrel): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (squirrel): the forbidden native body never ran"); resetFlags(); runLimited(&calogSquirrelEngine, "while(true){}", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (squirrel): a runaway loop is retired"); resetFlags(); runLimited(&calogSquirrelEngine, "local s = \"x\"; while(true){ s = s + s; }", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (squirrel): an over-budget script is retired"); // 9. Wren parity: the allow-list (engine-agnostic), the wall-clock budget and the memory cap // (both enforced from the bytecode loop's back-jump, so a runaway loop and an exponential // doubling are caught within one iteration). Wren reaches natives via Calog.call. resetFlags(); runLimited(&calogWrenEngine, "Calog.call(\"reached\", [])\nCalog.call(\"forbidden\", [])\nCalog.call(\"done\", [])", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (wren): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (wren): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (wren): the forbidden native body never ran"); resetFlags(); runLimited(&calogWrenEngine, "while (true) {}", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (wren): a runaway loop is retired"); resetFlags(); runLimited(&calogWrenEngine, "var s = \"x\"\nwhile (true) { s = s + s }", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (wren): an over-budget script is retired"); // Unbounded recursion executes no LOOP back-jump, so it is caught by the post-call check, not the // loop check -- a runaway that the loop-only check would have missed. resetFlags(); runLimited(&calogWrenEngine, "class R { static go() { R.go() } }\nR.go()", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (wren): runaway recursion is retired"); // 10. Janet parity: the allow-list (engine-agnostic), the wall-clock budget (an out-of-band // watchdog thread interrupts the VM at the deadline -- Janet has no in-loop time hook), and the // memory cap (the header allocator interrupts the VM on an over-cap allocation). resetFlags(); runLimited(&calogJanetEngine, "(reached) (forbidden) (done)", &allowLimits); CHECK(atomic_load(&reachedFlag), "allow-list (janet): a permitted native runs"); CHECK(atomic_load(&errorCount) >= 1, "allow-list (janet): calling a forbidden native errors"); CHECK(!atomic_load(&forbiddenFlag), "allow-list (janet): the forbidden native body never ran"); resetFlags(); runLimited(&calogJanetEngine, "(var i 0) (while true (set i (+ i 1)))", &timeLimits); CHECK(atomic_load(&errorCount) >= 1, "time budget (janet): a runaway loop is retired"); resetFlags(); runLimited(&calogJanetEngine, "(var s \"x\") (while true (set s (string s s)))", &memLimits); CHECK(atomic_load(&errorCount) >= 1, "memory cap (janet): an over-budget script is retired"); calogDestroy(calog); printf("\n%d checks, %d failed\n", testsRun, testsFailed); fflush(stdout); return testsFailed == 0 ? 0 : 1; }