// 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"); calogDestroy(calog); printf("\n%d checks, %d failed\n", testsRun, testsFailed); fflush(stdout); return testsFailed == 0 ? 0 : 1; }