312 lines
15 KiB
C
312 lines
15 KiB
C
// 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 <stdatomic.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#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;
|
|
}
|