calog/tests/testSandbox.c

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