From 15f8e1a61be9c553d8747ff7aff7c9123eab3a79 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Fri, 28 Aug 2026 20:53:00 -0400 Subject: [PATCH] feat(ext): embed sandboxed Lua runtime with C API --- src/ext/lua.c | 729 ++++++++++++++++++++++++++++++++++++++++++ src/ext/lua.h | 100 ++++++ tests/unit/test_lua.c | 295 +++++++++++++++++ 3 files changed, 1124 insertions(+) create mode 100644 src/ext/lua.c create mode 100644 src/ext/lua.h create mode 100644 tests/unit/test_lua.c diff --git a/src/ext/lua.c b/src/ext/lua.c new file mode 100644 index 0000000..bcffb81 --- /dev/null +++ b/src/ext/lua.c @@ -0,0 +1,729 @@ +/* + * src/ext/lua.c - embedded sandboxed Lua runtime (plan todo 19). + * + * Initializes the vendored Lua 5.4 as the stupidtools extension runtime + * with a SANDBOXED environment. See src/ext/lua.h for the exact + * sandbox contract (what is opened, blocked, removed) and the error + * contract of st_lua_run(). + * + * LINKING / THE -lm TRAP (important for the build wiring): + * The unit-test harness compiles every test file under tests/unit/ + * with NO extra libraries -- in particular no -lm. Stock Lua needs + * libm in TWO + * places: (1) lmathlib.c (sin/cos/pow/log/...), and (2) the CORE + * itself: lvm.c calls floor(), ltable.c frexp()+fabs(), lstrlib.c + * floor()+frexp(), lobject.c and lcode.c ldexp()+fabs(), and the + * arithmetic dispatcher luaO_arith() calls pow() and fmod(). On this + * host `floor` does NOT resolve without -lm (verified: gcc 16.2.1 / + * glibc 2.44 -- libm is a separate library). + * + * Two consequences: + * a) lmathlib.c and linit.c (which references luaopen_math) are NOT + * part of the link list; the curated `math` table below is + * written by hand with libm-free code, so extensions still get + * math.abs etc. (the plan's test requires math.abs(-3) to work). + * b) The core's remaining libm references are satisfied by the + * portable IEEE-754 binary64 shims at the top of this file + * (fabs/floor/frexp/ldexp/fmod/pow). They are correct for the + * common paths (the C23-annex-F binary64 assumption holds on + * gcc/clang x86-64/aarch64); pow's exotic sign edge cases are + * approximate. The main binary (todo 21+) may link the real + * libm later; these shims stay harmless because Lua's undefined + * references then simply resolve against libm instead... in + * fact they will NOT: our definitions take precedence. When the + * real libm becomes available, delete this block and add -lm. + * + * Include paths: the vendored headers are pulled in via path relative + * to THIS file (../../thirdparty/lua/...), because the unit-test + * harness only adds -I thirdparty/munit and -I src -- it never adds a + * Lua include dir. The file-relative form works under any invocation + * that compiles this file at its real location. + */ + +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L /* strdup (POSIX.1-2008) */ +#endif + +#include +#include +#include + +#include "../../thirdparty/lua/lauxlib.h" +#include "../../thirdparty/lua/lua.h" +#include "../../thirdparty/lua/lualib.h" + +#include "lua.h" + +/* ------------------------------------------------------------------ */ +/* libm-free math shims */ +/* ------------------------------------------------------------------ */ +/* + * All six are exactly the symbols the vendored Lua core references from + * libm (see the -lm note above). fabs uses the compiler builtin (a + * single ANDPS, no call); floor/frexp/ldexp/fmod/pow are hand-written. + * Bit-level code assumes IEEE 754 binary64 doubles (8-byte), which C23 + * annex F plus gcc/clang guarantee on the supported targets. + */ + +double fabs(double x); +double floor(double x); +double frexp(double x, int *e); +double ldexp(double x, int n); +double fmod(double x, double y); +double pow(double x, double y); + +double +fabs(double x) +{ + return __builtin_fabs(x); +} + +/* Truncation toward zero, safe for the full double range (casting an + * out-of-range double to long long is UB, hence the guard). */ +static double +l_shim_trunc(double x) +{ + static const double maxint = 9223372036854775808.0; /* 2^63 */ + + if (x > -maxint && x < maxint) { + return (double)(long long)x; + } + return x; /* NaN, +/-inf, or a magnitude already integral */ +} + +double +floor(double x) +{ + static const double minint = -9223372036854775808.0; + static const double maxint = 9223372036854775808.0; + + if (x == 0.0) return x; /* preserves the sign of zero */ + if (x > minint && x < maxint) { + double t = (double)(long long)x; + return x < t ? t - 1.0 : t; + } + return x; /* NaN, +/-inf, or out of range (already integral) */ +} + +double +frexp(double x, int *e) +{ + union { + double d; + unsigned long long u; + } v; + unsigned long long expfield; + + v.d = x; + expfield = (v.u >> 52) & 0x7FFULL; + if (expfield == 0x7FFULL) { /* inf or NaN: value returned unchanged */ + *e = 0; + return x; + } + if (expfield == 0) { /* zero or subnormal */ + if ((v.u & 0x000FFFFFFFFFFFFFULL) == 0ULL) { /* zero */ + *e = 0; + return x; + } + /* Normalize a subnormal by scaling it up 2^64 (the largest + * subnormal is ~2^-1022, so the result is normal), then fix + * the exponent. */ + x = frexp(x * 0x1p64, e); + *e -= 64; + return x; + } + /* Normal case: keep the fraction, force the exponent field to 1022 + * so the result lands in [0.5, 1), and report the difference. */ + *e = (int)expfield - 1022; + v.u = (v.u & 0x800FFFFFFFFFFFFFULL) | 0x3FE0000000000000ULL; + return v.d; +} + +double +ldexp(double x, int n) +{ + union { + double d; + unsigned long long u; + } v; + + if (n > 1023) { + x *= 0x1p1023; + n -= 1023; + if (n > 1023) { + x *= 0x1p1023; + n -= 1023; + if (n > 1023) n = 1023; + } + } else if (n < -1022) { + /* pre-scale so the final multiply stays clear of the subnormal + * range and rounds once (avoids double rounding) */ + x *= 0x1p-1022 * 0x1p53; + n += 1022 - 53; + if (n < -1022) { + x *= 0x1p-1022 * 0x1p53; + n += 1022 - 53; + if (n < -1022) n = -1022; + } + } + v.u = (unsigned long long)(0x3FF + n) << 52; + return x * v.d; +} + +double +fmod(double x, double y) +{ + double r; + double ax, ay; + int ex, ey; + int neg; + + if (y == 0.0 || x != x || y != y) return __builtin_nan(""); + if (x == 0.0) return x; /* keeps the sign */ + if (fabs(x) == __builtin_inf()) return __builtin_nan(""); + if (fabs(y) == __builtin_inf()) return x; /* fmod(x, inf) = x */ + + neg = x < 0.0; + ax = fabs(x); + ay = fabs(y); + frexp(ax, &ex); + frexp(ay, &ey); + /* Binary long division: subtract the largest multiple of ay that + * fits, then halve. Bounded by the exponent range (~2100 steps). */ + r = ax; + while (ex >= ey && r != 0.0) { + double s = ldexp(ay, ex - ey); + if (r >= s) r -= s; + ex--; + } + return neg ? -r : r; +} + +/* e^x via the reduction x = k*ln(2) + r, |r| <= ln(2)/2, then a Taylor + * series (20 terms: the truncation error is ~4e-30, far below double + * precision). */ +static double +l_shim_exp(double x) +{ + static const double ln2 = 0.69314718055994530942; + static const double invln2 = 1.44269504088896340736; + long long k; + double r, t, y; + int i; + + if (x > 709.7827128933839731) return __builtin_inf(); /* overflow */ + if (x < -745.1332191019411085) return 0.0; /* underflow */ + if (x != x) return x; /* NaN */ + + k = (long long)(x * invln2 + (x >= 0.0 ? 0.5 : -0.5)); + r = x - (double)k * ln2; + y = 1.0; + t = 1.0; + for (i = 1; i <= 20; i++) { + t = t * (r / (double)i); + y += t; + } + return ldexp(y, (int)k); +} + +/* ln(x) via x = m * 2^e (m in [0.5, 1)), then the atanh series around + * m = 2/3: log(m) = log(2/3) + 2*atanh(t), t = (m - 2/3)/(m + 2/3), + * |t| <= 0.2. 12 terms leave ~1e-18, i.e. a few ulps. */ +static double +l_shim_log(double x) +{ + static const double ln2 = 0.69314718055994530942; + static const double log32 = -0.40546510810816438098; /* ln(2/3) */ + double m, t, p, term, sum; + int e; + int i; + + if (x < 0.0 || x != x) return __builtin_nan(""); + if (x == 0.0) return -__builtin_inf(); + if (x == __builtin_inf()) return x; + + m = frexp(x, &e); + t = (m - 0.66666666666666663) / (m + 0.66666666666666663); + p = t * t; + term = t; + sum = t; + for (i = 1; i <= 12; i++) { + term *= p; + sum += term / (double)(2 * i + 1); + } + return (double)e * ln2 + log32 + 2.0 * sum; +} + +double +pow(double x, double y) +{ + if (y == 0.0) return 1.0; /* even pow(NaN, 0) == 1 */ + if (x == 1.0) return 1.0; + if ((x == -1.0) && + (y == __builtin_inf() || y == -__builtin_inf())) return 1.0; + if (x != x || y != y) return __builtin_nan(""); + + /* Integral exponent within the exactly-representable range: exact + * repeated squaring (this also covers negative bases). */ + if (y == l_shim_trunc(y) && y > -9007199254740992.0 && + y < 9007199254740992.0) { + long long n = (long long)y; + unsigned long long m = n < 0 ? (unsigned long long)(-n) + : (unsigned long long)n; + double r = 1.0; + double b = x; + while (m != 0ULL) { + if ((m & 1ULL) != 0ULL) r *= b; + b *= b; + m >>= 1; + } + return n < 0 ? 1.0 / r : r; + } + + if (x == __builtin_inf()) return y > 0.0 ? x : 0.0; + if (x == -__builtin_inf()) return y > 0.0 ? __builtin_inf() : 0.0; + if (x == 0.0) return y > 0.0 ? 0.0 : __builtin_inf(); + if (y == __builtin_inf()) return x > 1.0 ? __builtin_inf() : 0.0; + if (y == -__builtin_inf()) return x < 1.0 ? __builtin_inf() : 0.0; + if (x < 0.0) return __builtin_nan(""); /* negative base, non-int y */ + return l_shim_exp(y * l_shim_log(x)); +} + +/* ------------------------------------------------------------------ */ +/* runtime state */ +/* ------------------------------------------------------------------ */ + +struct st_lua_reg { + struct st_lua_reg *next; + char *name; /* owned copy */ + void *probe_spec; /* checks only; always NULL for languages */ +}; + +struct st_lua_rt { + lua_State *L; + struct st_lua_reg *checks; /* insertion-ordered linked lists */ + struct st_lua_reg *languages; + size_t check_count; + size_t language_count; +}; + +/* Appends (name, spec) at the TAIL of *head (insertion order, matching + * the ABI registries' contract) unless it is a duplicate. Returns 0 on + * success, -1 on duplicate or allocation failure. */ +static int +reg_append(struct st_lua_reg **head, size_t *count, const char *name, + void *spec) +{ + struct st_lua_reg *n, *p; + + for (p = *head; p != NULL; p = p->next) { + if (strcmp(p->name, name) == 0) return -1; /* duplicate */ + } + n = malloc(sizeof *n); + if (n == NULL) return -1; + n->name = strdup(name); + if (n->name == NULL) { + free(n); + return -1; + } + n->probe_spec = spec; + n->next = NULL; + if (*head == NULL) { + *head = n; + } else { + for (p = *head; p->next != NULL; p = p->next) { + } + p->next = n; + } + *count += 1; + return 0; +} + +static void +regs_free(struct st_lua_reg *head) +{ + struct st_lua_reg *p = head; + while (p != NULL) { + struct st_lua_reg *next = p->next; + free(p->name); + free(p); + p = next; + } +} + +static const struct st_lua_reg * +reg_at(const struct st_lua_reg *head, size_t i) +{ + const struct st_lua_reg *p = head; + while (p != NULL && i > 0) { + p = p->next; + i--; + } + return p; +} + +/* ------------------------------------------------------------------ */ +/* sandboxed globals */ +/* ------------------------------------------------------------------ */ + +/* The one string sandbox violations contain; st_lua_run() classifies + * errors by finding it in the message. Keep in sync with l_os_blocked(). */ +#define SANDBOX_PREFIX "sandbox blocked: " + +/* os.execute / os.exit / os.remove / os.rename all land here: a stub + * that raises a clear sandbox error no matter how it is called (direct, + * pcall'd, aliased, ...). */ +static int +l_os_blocked(lua_State *L) +{ + const char *name = lua_tostring(L, lua_upvalueindex(1)); + return luaL_error(L, SANDBOX_PREFIX "os.%s is not available to " + "extension code", + name != NULL ? name : "?"); +} + +/* The ONLY os table extensions see: the four stubs above, nothing else + * (no clock/date/getenv -- extension code needs none of them in v1 and + * a smaller surface is a better sandbox). The stock loslib is not even + * linked. */ +static void +l_os_open_sandboxed(lua_State *L) +{ + static const char *const blocked[] = { "execute", "exit", "remove", + "rename", NULL }; + int i; + + lua_newtable(L); + for (i = 0; blocked[i] != NULL; i++) { + lua_pushstring(L, blocked[i]); /* upvalue: the function's name */ + lua_pushcclosure(L, l_os_blocked, 1); + lua_setfield(L, -2, blocked[i]); + } + lua_setglobal(L, "os"); +} + +/* Curated, libm-FREE math library (stock lmathlib would pull sin/cos/ + * pow/log/... from libm; see the header note). abs/max/min/tointeger/ + * type/maxinteger/mininteger/pi/huge cover what extension code needs + * in v1; floor/ceil/sqrt etc. arrive when the main binary links real + * libm (todo 21+). */ +static int +l_math_abs(lua_State *L) +{ + if (lua_isinteger(L, 1)) { + lua_Integer n = lua_tointeger(L, 1); + lua_pushinteger(L, n < 0 ? (lua_Integer)(0u - (lua_Unsigned)n) : n); + } else { + lua_pushnumber(L, fabs(luaL_checknumber(L, 1))); + } + return 1; +} + +static int +l_math_max(lua_State *L) +{ + int n = lua_gettop(L); + int i; + lua_Number m; + + if (n == 0) return luaL_error(L, "math.max: expected at least one argument"); + m = luaL_checknumber(L, 1); + for (i = 2; i <= n; i++) { + lua_Number v = luaL_checknumber(L, i); + if (v > m) m = v; + } + lua_pushnumber(L, m); + return 1; +} + +static int +l_math_min(lua_State *L) +{ + int n = lua_gettop(L); + int i; + lua_Number m; + + if (n == 0) return luaL_error(L, "math.min: expected at least one argument"); + m = luaL_checknumber(L, 1); + for (i = 2; i <= n; i++) { + lua_Number v = luaL_checknumber(L, i); + if (v < m) m = v; + } + lua_pushnumber(L, m); + return 1; +} + +/* Mirrors stock math.tointeger: converts a value that has an exact + * integer representation (this exercises the floor() shim through + * luaV_flttointeger). */ +static int +l_math_tointeger(lua_State *L) +{ + int valid; + lua_Integer n = lua_tointegerx(L, 1, &valid); + + if (valid) { + lua_pushinteger(L, n); + } else { + luaL_checkany(L, 1); + lua_pushnil(L); /* not convertible */ + } + return 1; +} + +static int +l_math_type(lua_State *L) +{ + if (lua_type(L, 1) == LUA_TNUMBER) { + if (lua_isinteger(L, 1)) { + lua_pushliteral(L, "integer"); + } else { + lua_pushliteral(L, "float"); + } + } else { + luaL_checkany(L, 1); + lua_pushnil(L); + } + return 1; +} + +static void +l_math_open(lua_State *L) +{ + static const luaL_Reg funcs[] = { + { "abs", l_math_abs }, + { "max", l_math_max }, + { "min", l_math_min }, + { "tointeger", l_math_tointeger }, + { "type", l_math_type }, + { NULL, NULL }, + }; + + luaL_newlib(L, funcs); + lua_pushinteger(L, LUA_MAXINTEGER); + lua_setfield(L, -2, "maxinteger"); + lua_pushinteger(L, LUA_MININTEGER); + lua_setfield(L, -2, "mininteger"); + lua_pushnumber(L, 3.14159265358979323846); + lua_setfield(L, -2, "pi"); + lua_pushnumber(L, __builtin_inf()); + lua_setfield(L, -2, "huge"); + lua_setglobal(L, "math"); +} + +/* ------------------------------------------------------------------ */ +/* registration API (Lua side) */ +/* ------------------------------------------------------------------ */ + +/* The `st` table: st.register_check(name) / st.register_language(name). + * Both write into the runtime's own registries through the same C API + * todo 21 will enumerate when bridging into a struct st_ext_ctx. The + * rt travels as a light-userdata upvalue -- there is no global state. */ + +static int +l_st_register_check(lua_State *L) +{ + struct st_lua_rt *rt = lua_touserdata(L, lua_upvalueindex(1)); + size_t len; + const char *name; + + if (lua_type(L, 1) != LUA_TSTRING) { + return luaL_typeerror(L, 1, "string"); + } + name = lua_tostring(L, 1); + len = lua_rawlen(L, 1); + if (name == NULL || strlen(name) != len) { + return luaL_error(L, "check name must not contain a NUL byte"); + } + if (st_lua_register_check(rt, name, NULL) != 0) { + return luaL_error(L, "check '%s' is already registered", name); + } + return 0; +} + +static int +l_st_register_language(lua_State *L) +{ + struct st_lua_rt *rt = lua_touserdata(L, lua_upvalueindex(1)); + size_t len; + const char *name; + + if (lua_type(L, 1) != LUA_TSTRING) { + return luaL_typeerror(L, 1, "string"); + } + name = lua_tostring(L, 1); + len = lua_rawlen(L, 1); + if (name == NULL || strlen(name) != len) { + return luaL_error(L, "language name must not contain a NUL byte"); + } + if (st_lua_register_language(rt, name) != 0) { + return luaL_error(L, "language '%s' is already registered", name); + } + return 0; +} + +static void +l_st_open(lua_State *L, struct st_lua_rt *rt) +{ + lua_newtable(L); + lua_pushlightuserdata(L, rt); + lua_pushcclosure(L, l_st_register_check, 1); + lua_setfield(L, -2, "register_check"); + lua_pushlightuserdata(L, rt); + lua_pushcclosure(L, l_st_register_language, 1); + lua_setfield(L, -2, "register_language"); + lua_setglobal(L, "st"); +} + +/* ------------------------------------------------------------------ */ +/* runtime lifecycle + sandbox assembly */ +/* ------------------------------------------------------------------ */ + +struct st_lua_rt * +st_lua_rt_new(void) +{ + struct st_lua_rt *rt = calloc(1, sizeof *rt); + lua_State *L; + + if (rt == NULL) return NULL; + L = luaL_newstate(); + if (L == NULL) { + free(rt); + return NULL; + } + rt->L = L; + + /* Selective library set -- deliberately NOT luaL_openlibs(): + * base / table / string are the only stock libraries loaded; + * math and os are the hand-built sandboxed ones below; + * package/io/debug/coroutine are never loaded (and their .c + * files are not even linked -- see the -lm note at the top); + * lmathlib/linit are excluded from the link for the same reason. */ + luaL_requiref(L, "base", luaopen_base, 1); + lua_pop(L, 1); + luaL_requiref(L, "table", luaopen_table, 1); + lua_pop(L, 1); + luaL_requiref(L, "string", luaopen_string, 1); + lua_pop(L, 1); + + /* Remove the base-library globals that reach files/modules. (`load` + * is kept: with io/os/package gone it can only compile strings, and + * that is how extension loading works in todo 20/21.) */ + lua_pushnil(L); + lua_setglobal(L, "dofile"); + lua_pushnil(L); + lua_setglobal(L, "loadfile"); + lua_pushnil(L); + lua_setglobal(L, "require"); /* defensive: 5.4 defines it in loadlib */ + + l_os_open_sandboxed(L); + l_math_open(L); + l_st_open(L, rt); + return rt; +} + +void +st_lua_rt_free(struct st_lua_rt *rt) +{ + if (rt == NULL) return; + if (rt->L != NULL) lua_close(rt->L); + regs_free(rt->checks); + regs_free(rt->languages); + free(rt); +} + +/* ------------------------------------------------------------------ */ +/* registration API (C side) + todo-21 enumeration bridge */ +/* ------------------------------------------------------------------ */ + +int +st_lua_register_check(struct st_lua_rt *rt, const char *name, + void *probe_spec_or_null) +{ + if (rt == NULL || name == NULL || name[0] == '\0') return -1; + return reg_append(&rt->checks, &rt->check_count, name, probe_spec_or_null); +} + +int +st_lua_register_language(struct st_lua_rt *rt, const char *name) +{ + if (rt == NULL || name == NULL || name[0] == '\0') return -1; + return reg_append(&rt->languages, &rt->language_count, name, NULL); +} + +size_t +st_lua_check_count(const struct st_lua_rt *rt) +{ + return rt != NULL ? rt->check_count : 0; +} + +const char * +st_lua_check_name(const struct st_lua_rt *rt, size_t i) +{ + const struct st_lua_reg *r = rt != NULL ? reg_at(rt->checks, i) : NULL; + return r != NULL ? r->name : NULL; +} + +void * +st_lua_check_probe_spec(const struct st_lua_rt *rt, size_t i) +{ + const struct st_lua_reg *r = rt != NULL ? reg_at(rt->checks, i) : NULL; + return r != NULL ? r->probe_spec : NULL; +} + +size_t +st_lua_language_count(const struct st_lua_rt *rt) +{ + return rt != NULL ? rt->language_count : 0; +} + +const char * +st_lua_language_name(const struct st_lua_rt *rt, size_t i) +{ + const struct st_lua_reg *r = rt != NULL ? reg_at(rt->languages, i) : NULL; + return r != NULL ? r->name : NULL; +} + +/* ------------------------------------------------------------------ */ +/* execution */ +/* ------------------------------------------------------------------ */ + +/* Takes the error object Lua left on the top of the stack, classifies + * it (sandbox violation vs. everything else), builds the st_error and + * pops the object. */ +static struct st_error * +lua_error_to_st_error(lua_State *L) +{ + const char *msg = lua_tostring(L, -1); + struct st_error *err; + + if (msg == NULL) msg = "(non-string Lua error)"; + /* luaL_error prepends "chunkname:line: " to the message, so the + * sandbox marker is matched as a substring, not a prefix. */ + if (strstr(msg, SANDBOX_PREFIX) != NULL) { + err = st_error_kdl_schema(msg); + } else { + err = st_error_internal(msg); + } + lua_pop(L, 1); + return err; +} + +struct st_error * +st_lua_run(struct st_lua_rt *rt, const char *chunk, const char *chunkname) +{ + lua_State *L; + int status; + + if (rt == NULL) return st_error_internal("st_lua_run: NULL runtime"); + if (chunk == NULL) return st_error_internal("st_lua_run: NULL chunk"); + L = rt->L; + + status = luaL_loadbuffer(L, chunk, strlen(chunk), + chunkname != NULL ? chunkname : "=(extension)"); + if (status == LUA_OK) { + status = lua_pcall(L, 0, 0, 0); + } + if (status == LUA_OK) return NULL; + return lua_error_to_st_error(L); +} diff --git a/src/ext/lua.h b/src/ext/lua.h new file mode 100644 index 0000000..ea67dd4 --- /dev/null +++ b/src/ext/lua.h @@ -0,0 +1,100 @@ +#ifndef ST_EXT_LUA_H +#define ST_EXT_LUA_H + +/* + * Embedded sandboxed Lua runtime for stupidtools extensions (plan todo + * 19). Binds the vendored Lua 5.4 (thirdparty/lua/) as the extension + * language; the extension ABI (src/ext/abi.h, todo 13) is bridged in + * todo 21 -- THIS todo only ships the runtime, the sandbox, and the + * registration hooks. + * + * Sandbox contract (what extension code may and may not do): + * + * OPENED libraries (the ONLY ones linked and loaded): + * base -- minus the globals below + * table, string + * math -- a curated, libm-FREE subset written here: abs, max, + * min, tointeger, type, maxinteger, mininteger, pi, + * huge. The stock lmathlib.c is NOT linked: it pulls + * sin/cos/pow/... from libm and the unit-test harness + * links NO extra libraries (-lm is unavailable). + * os -- a hand-built table containing ONLY the stubs below. + * + * BLOCKED (exist but raise "sandbox blocked: ..." errors): + * os.execute, os.exit, os.remove, os.rename + * + * REMOVED (globals are nil; their C libraries are not even linked): + * dofile, loadfile, require, package, io, debug, coroutine + * + * KEPT with the rationale that they cannot reach the filesystem or a + * process without io/os/package (all gone): `load` (string chunks + * only), collectgarbage, print, warn, the string/table libraries. + * + * REGISTRATION API (the only way extensions talk to stupidtools): + * st.register_check("name") + * st.register_language("name") + * These record into the RUNTIME'S OWN registries (see below); the + * flush into a struct st_ext_ctx is todo 21's bridge. + * + * Error contract of st_lua_run(): + * - success -> NULL; + * - sandbox violation -> st_error, category ST_ERR_KDL_SCHEMA, whose + * message contains "sandbox blocked: " (the extension violated + * its contract -- the closest existing category; error.h is frozen + * to this todo); + * - any other Lua failure (syntax error, runtime error, OOM) -> + * st_error, category ST_ERR_INTERNAL, carrying Lua's message + * (chunkname is propagated into syntax-error messages). + */ + +#include + +#include "error.h" + +struct st_lua_rt; /* opaque */ + +/* Fresh runtime with the sandboxed environment installed. NULL only on + * allocation failure. */ +struct st_lua_rt *st_lua_rt_new(void); + +/* NULL is a no-op. */ +void st_lua_rt_free(struct st_lua_rt *rt); + +/* ---------------- registration API (C side) ---------------- */ + +/* Record a check registration in the runtime's own registry. `name` + * must be a non-empty NUL-terminated string; `probe_spec_or_null` is an + * opaque pointer kept verbatim (todo 21 passes it through to the ABI's + * st_ext_register_check). Returns 0 on success, -1 on bad arguments, + * duplicate name, or allocation failure. Mirrored from Lua by + * st.register_check(name). */ +int st_lua_register_check(struct st_lua_rt *rt, const char *name, + void *probe_spec_or_null); + +/* Same, for languages (mirrored by st.register_language(name)). */ +int st_lua_register_language(struct st_lua_rt *rt, const char *name); + +/* ---------------- enumeration (the todo 21 bridge) ---------------- + * + * Everything the runtime has recorded, in registration order. Todo 21 + * walks these and calls st_ext_register_check / st_ext_register_language + * on a struct st_ext_ctx -- no state lives anywhere else. */ + +size_t st_lua_check_count(const struct st_lua_rt *rt); +const char *st_lua_check_name(const struct st_lua_rt *rt, size_t i); +void *st_lua_check_probe_spec(const struct st_lua_rt *rt, size_t i); + +size_t st_lua_language_count(const struct st_lua_rt *rt); +const char *st_lua_language_name(const struct st_lua_rt *rt, size_t i); + +/* ---------------- execution ---------------- */ + +/* Compiles `chunk` (NUL-terminated; must not be NULL) and runs it in + * the sandbox. `chunkname` may be NULL and only feeds diagnostics. + * Returns NULL on success; otherwise see the error contract above (the + * caller owns the returned st_error). The runtime stays usable after an + * error. */ +struct st_error *st_lua_run(struct st_lua_rt *rt, const char *chunk, + const char *chunkname); + +#endif /* ST_EXT_LUA_H */ diff --git a/tests/unit/test_lua.c b/tests/unit/test_lua.c new file mode 100644 index 0000000..e69f0fa --- /dev/null +++ b/tests/unit/test_lua.c @@ -0,0 +1,295 @@ +/* LINK: ../../src/ext/lua.c ../../src/error.c ../../src/span.c ../../thirdparty/lua/lapi.c ../../thirdparty/lua/lauxlib.c ../../thirdparty/lua/lbaselib.c ../../thirdparty/lua/lcode.c ../../thirdparty/lua/lctype.c ../../thirdparty/lua/ldebug.c ../../thirdparty/lua/ldo.c ../../thirdparty/lua/ldump.c ../../thirdparty/lua/lfunc.c ../../thirdparty/lua/lgc.c ../../thirdparty/lua/llex.c ../../thirdparty/lua/lmem.c ../../thirdparty/lua/lobject.c ../../thirdparty/lua/lopcodes.c ../../thirdparty/lua/lparser.c ../../thirdparty/lua/lstate.c ../../thirdparty/lua/lstring.c ../../thirdparty/lua/ltable.c ../../thirdparty/lua/ltm.c ../../thirdparty/lua/lundump.c ../../thirdparty/lua/lvm.c ../../thirdparty/lua/lzio.c ../../thirdparty/lua/lstrlib.c ../../thirdparty/lua/ltablib.c */ +/* tests/unit/test_lua.c + * + * Unit tests for the embedded sandboxed Lua runtime (src/ext/lua.h, + * plan todo 19). + * + * The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it lists + * the extra .c sources to compile into this test binary (paths relative + * to tests/unit/, space-separated). munit.c and the include dirs are + * added automatically by the harness. + * + * LINK LIST POLICY (the -lm trap): + * The harness compiles with NO extra libraries -- in particular NO + * -lm. Stock Lua needs libm in two ways: (1) lmathlib.c (sin/cos/pow/ + * log/...) and (2) the CORE itself (floor in lvm.c, frexp/fabs in + * ltable.c, ldexp in lobject.c/lcode.c, fmod and pow via luaO_arith). + * Therefore this test links: + * - the 23 core .c files + lauxlib + lbaselib + lstrlib + ltablib; + * - NOT lmathlib, linit (which references luaopen_math), loadlib + * (package/require), liolib (io), loslib (we build our own + * sandboxed os table), ldblib (debug), lcorolib, lutf8lib; + * - NOT the lua.c / luac.c standalone mains. + * The remaining core libm references are satisfied by portable + * libm-free shims (fabs/floor/frexp/ldexp/fmod/pow) defined in + * src/ext/lua.c itself -- the binary links with zero libm code. + */ +#include "munit.h" + +#include "ext/lua.h" + +#include +#include + +/* ---------- shared chunks ---------- */ + +/* Registers one check and one language from Lua; every registration in + * this file must be visible through the C enumeration API afterwards. */ +static const char reg_chunk[] = + "st.register_check(\"magic\")\n" + "st.register_language(\"fortran\")\n"; + +/* Every dangerous entry point must raise a "sandbox blocked:" error even + * when wrapped in pcall, and the module-loading / file-loading globals + * must not exist at all. */ +static const char audit_chunk[] = + "local function must_block(fn)\n" + " local ok, err = pcall(fn)\n" + " assert(ok == false, \"expected blocked call to succeed?\")\n" + " assert(err:match(\"sandbox blocked\"), \"expected sandbox error, got: \" .. tostring(err))\n" + "end\n" + "must_block(function() os.execute(\"id\") end)\n" + "must_block(function() os.exit(0) end)\n" + "must_block(function() os.remove(\"/etc/passwd\") end)\n" + "must_block(function() os.rename(\"a\", \"b\") end)\n" + "assert(io == nil, \"io must not exist\")\n" + "assert(package == nil, \"package must not exist\")\n" + "assert(require == nil, \"require must not exist\")\n" + "assert(dofile == nil, \"dofile must not exist\")\n" + "assert(loadfile == nil, \"loadfile must not exist\")\n"; + +/* The curated math library (libm-free subset) must work. */ +static const char math_chunk[] = + "assert(math.abs(-3) == 3)\n" + "assert(math.type(3) == \"integer\")\n" + "assert(math.type(1.5) == \"float\")\n" + "assert(math.tointeger(3.0) == 3)\n" + "assert(math.tointeger(1.5) == nil)\n" + "assert(math.max(1, 5, 3) == 5)\n" + "assert(math.min(2, -1) == -1)\n" + "assert(math.pi > 3.14 and math.pi < 3.15)\n" + "assert(math.huge > 1e300)\n"; + +/* Operator smoke test for the libm-free shims inside src/ext/lua.c: + * ^ -> pow % -> fmod 0x1p3 -> ldexp (compile-time folding) + * float table keys -> frexp math.tointeger -> floor + */ +static const char shim_chunk[] = + "assert(2.5 ^ 2 == 6.25)\n" + "assert(2.5 % 1.0 == 0.5)\n" + "assert(0x1p3 == 8.0)\n" + "assert(1e300 > 1e100)\n" + "local t = { [1.5] = \"x\" }\n" + "assert(t[1.5] == \"x\")\n" + "assert(string.format(\"%.1f\", 1.5) == \"1.5\")\n"; + +/* ---------- (a) registrations land in the runtime's own registry ---------- */ + +static MunitResult +test_registration_visible(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + err = st_lua_run(rt, reg_chunk, "=regtest"); + munit_assert_null(err); + + munit_assert_size(st_lua_check_count(rt), ==, 1); + munit_assert_string_equal(st_lua_check_name(rt, 0), "magic"); + munit_assert_null(st_lua_check_probe_spec(rt, 0)); + munit_assert_size(st_lua_language_count(rt), ==, 1); + munit_assert_string_equal(st_lua_language_name(rt, 0), "fortran"); + + /* C-side registration writes into the same registry (insertion + * order, so it is index 1). */ + munit_assert_int(st_lua_register_check(rt, "via-c", NULL), ==, 0); + munit_assert_size(st_lua_check_count(rt), ==, 2); + munit_assert_string_equal(st_lua_check_name(rt, 0), "magic"); + munit_assert_string_equal(st_lua_check_name(rt, 1), "via-c"); + + st_lua_rt_free(rt); + return MUNIT_OK; +} + +/* ---------- (b) sandbox: os.execute & friends are blocked ---------- */ + +static MunitResult +test_sandbox_blocks_exec(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + const char *msg; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + /* direct call: error carries the actual block reason */ + err = st_lua_run(rt, "os.execute(\"id\")", "=evil"); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + msg = st_error_message(err); + munit_assert_not_null(msg); + munit_assert_not_null(strstr(msg, "sandbox blocked")); + munit_assert_not_null(strstr(msg, "os.execute")); + st_error_free(err); + + /* pcall must not be an escape hatch: the call still fails, and the + * captured error still names the sandbox block. */ + err = st_lua_run(rt, + "local ok, e = pcall(os.execute, \"id\")\n" + "assert(ok == false)\n" + "assert(e:match(\"sandbox blocked\"))\n", + "=evil-pcall"); + munit_assert_null(err); + + /* the rest of the dangerous surface, audited from inside the sandbox */ + err = st_lua_run(rt, audit_chunk, "=audit"); + munit_assert_null(err); + + st_lua_rt_free(rt); + return MUNIT_OK; +} + +/* ---------- (c) syntax errors yield a clean st_error ---------- */ + +static MunitResult +test_syntax_error_clean(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + const char *msg; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + err = st_lua_run(rt, "local x =", "broken.lua"); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_INTERNAL); + msg = st_error_message(err); + munit_assert_not_null(msg); + munit_assert_size(strlen(msg), >, 0); + munit_assert_not_null(strstr(msg, "broken.lua")); /* chunkname propagated */ + st_error_free(err); + + /* the runtime stays usable after an error */ + err = st_lua_run(rt, "local y = 1", "=ok"); + munit_assert_null(err); + + st_lua_rt_free(rt); + return MUNIT_OK; +} + +/* ---------- (d) math is allowed (curated, libm-free subset) ---------- */ + +static MunitResult +test_math_allowed(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + err = st_lua_run(rt, math_chunk, "=math"); + munit_assert_null(err); + + st_lua_rt_free(rt); + return MUNIT_OK; +} + +/* ---------- operator smoke for the libm-free shims ---------- */ + +static MunitResult +test_shims_through_operators(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + err = st_lua_run(rt, shim_chunk, "=shims"); + munit_assert_null(err); + + st_lua_rt_free(rt); + return MUNIT_OK; +} + +/* ---------- defensive API behavior: NULL args, duplicates ---------- */ + +static MunitResult +test_null_and_dup_defensive(const MunitParameter params[], void *data) +{ + struct st_lua_rt *rt; + struct st_error *err; + + (void)params; + (void)data; + + rt = st_lua_rt_new(); + munit_assert_not_null(rt); + + err = st_lua_run(rt, NULL, NULL); + munit_assert_not_null(err); + st_error_free(err); + + munit_assert_int(st_lua_register_check(rt, NULL, NULL), ==, -1); + munit_assert_int(st_lua_register_language(rt, NULL), ==, -1); + munit_assert_int(st_lua_register_check(rt, "", NULL), ==, -1); + + /* duplicates are rejected C-side and Lua-side */ + munit_assert_int(st_lua_register_check(rt, "dup", NULL), ==, 0); + munit_assert_int(st_lua_register_check(rt, "dup", NULL), ==, -1); + err = st_lua_run(rt, "st.register_check(\"dup\")", "=dup"); + munit_assert_not_null(err); + st_error_free(err); + + /* freeing NULL is a no-op */ + st_lua_rt_free(NULL); + st_lua_rt_free(rt); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/lua/registration-visible", test_registration_visible, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/lua/sandbox-blocks-exec", test_sandbox_blocks_exec, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/lua/syntax-error-clean", test_syntax_error_clean, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/lua/math-allowed", test_math_allowed, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/lua/shims-through-operators", test_shims_through_operators, NULL, + NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/lua/null-and-dup-defensive", test_null_and_dup_defensive, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/lua", tests, NULL, 1, MUNIT_SUITE_OPTION_NONE, +}; + +int +main(int argc, char *argv[MUNIT_ARRAY_PARAM(argc + 1)]) +{ + return munit_suite_main(&suite, NULL, argc, argv); +}