diff --git a/README.md b/README.md index 478793c..e244203 100644 --- a/README.md +++ b/README.md @@ -152,19 +152,25 @@ by the engine. Called when Lua executes `redis.pcall(...)`. Return `{ err: Buffer, code?: Buffer }` instead of throwing to match Redis behavior. -### Error decoration +### Error metadata -The engine, not the host, owns Redis error formatting: +The engine composes **no** user-facing error wording — it classifies the error and +lets the host render. When a script aborts, the reply carries: -- An error that **aborts** a script (a `redis.call` error that propagates out, or an - uncaught Lua runtime error) is decorated with `script: , on @user_script:.`, - matching Redis. -- An error **value** the script returns (e.g. `return redis.pcall(...)`) is passed - through untouched. +- `code` — the RESP error class (e.g. `WRONGTYPE`, default `ERR`); preserved from + `redis.call`. See [Reply Types](#reply-types). +- `meta` — `{ line, sha }` always, plus `{ kind, name }` for errors the engine itself + originates (the globals protection). `kind` is an opaque machine tag the host maps + to wording (`global-read`, `global-write`); `name` is the variable involved. +- `err` — for engine-originated errors, the bare `kind` (a machine default). For Lua + runtime / `redis.call` errors, the original message, passed through untouched. -So hosts should return plain, undecorated error messages; the engine adds the script -context. The error `code` (e.g. `WRONGTYPE`) is preserved through this process — see -[Reply Types](#reply-types). +The host owns wording: map `kind` to the Redis message (version-specific if you care) +and decorate with `line`/`sha` as needed +(` script: , on @user_script:.`). An error **value** the script +returns (e.g. `return redis.pcall(...)`) is passed through untouched. + +Hosts should return plain, undecorated error messages. ### log @@ -181,7 +187,7 @@ type ReplyValue = | bigint // Integer (64-bit) | Buffer // Bulk string | { ok: Buffer } // Status reply (+OK) - | { err: Buffer; code?: Buffer } // Error reply (-ERR); code is the leading token, e.g. WRONGTYPE + | { err: Buffer; code?: Buffer; meta?: ReplyErrorMeta } // Error reply (-ERR); code e.g. WRONGTYPE, meta for rendering | ReplyValue[]; // Array ``` diff --git a/package-lock.json b/package-lock.json index e62f020..78e81eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lua-redis-wasm", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lua-redis-wasm", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^6.0.0", diff --git a/package.json b/package.json index 50b151a..b154e40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lua-redis-wasm", - "version": "1.4.0", + "version": "1.4.1", "description": "WebAssembly-based Redis Lua 5.1 script engine for Node.js - Execute Redis-compatible Lua scripts without a live Redis server", "keywords": [ "redis", diff --git a/src/engine.ts b/src/engine.ts index 844f56c..2adb5f2 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -51,6 +51,7 @@ import type { EngineLimits, LoadOptions, ReplyValue, + ReplyErrorMeta, RedisHost, RedisCallHandler, RedisLogHandler, @@ -336,7 +337,7 @@ export class LuaEngine { typeof value === "object" && "err" in value ) { - return decorateScriptError(value, sha); + return buildScriptError(value, sha); } return value; @@ -344,24 +345,70 @@ export class LuaEngine { } /** - * Appends the Redis script source context to a script-aborting error message. + * Builds a script-aborting error reply. The engine composes no user-facing prose: + * + * - Engine-originated errors (globals protection) arrive as a coded marker; we + * forward `{ kind, name }` in `meta` and the host chooses the wording. `err` + * carries the bare `kind` as a machine-readable default. + * - Lua runtime / redis.call errors already carry their own message (and code); + * they pass through untouched, with only `line`/`sha` attached for the host to + * decorate. * * Lua runtime errors carry a `user_script:N:` prefix (N is the line); command * errors propagated out of redis.call have no prefix and are reported at line 1. - * The error `code` (if any) is preserved. */ -function decorateScriptError( +function buildScriptError( value: { err: Buffer; code?: Buffer }, sha: string, -): ReplyValue { +): { err: Buffer; code: Buffer; meta: ReplyErrorMeta } { const errStr = value.err.toString("utf8"); - let line = "1"; + let line = 1; if (errStr.startsWith("user_script:")) { const colonIdx = errStr.indexOf(":", 12); // after "user_script:" - line = colonIdx > 12 ? errStr.substring(12, colonIdx) : "1"; + if (colonIdx > 12) { + line = Number(errStr.substring(12, colonIdx)) || 1; + } + } + + const marker = parseErrorMarker(errStr); + if (marker) { + return { + err: Buffer.from(marker.kind, "utf8"), + code: Buffer.from("ERR", "utf8"), + meta: { kind: marker.kind, name: marker.name, line, sha }, + }; + } + + return { + err: value.err, + // Preserve a propagated command code (e.g. WRONGTYPE); otherwise "ERR". + code: value.code ?? Buffer.from("ERR", "utf8"), + meta: { line, sha }, + }; +} + +const ERROR_MARKER = "__RLUA_E__:"; + +/** + * Engine-originated errors (globals protection, see runtime.c) cross the + * Lua->WASM->JS boundary as a coded string `__RLUA_E__::` (Lua errors + * carry no type tag, so the discriminator travels in the string). Splits out the + * opaque `kind` and `name`; the library forwards them and never interprets the + * kind. Returns undefined for ordinary error messages. + */ +function parseErrorMarker( + errStr: string, +): { kind: string; name: string } | undefined { + const idx = errStr.indexOf(ERROR_MARKER); + if (idx < 0) { + return undefined; + } + const rest = errStr.slice(idx + ERROR_MARKER.length); // ":" + const sep = rest.indexOf(":"); + if (sep < 0) { + return undefined; } - const formatted = `${errStr} script: ${sha}, on @user_script:${line}.`; - return { err: Buffer.from(formatted, "utf8"), code: value.code }; + return { kind: rest.slice(0, sep), name: rest.slice(sep + 1) }; } /** diff --git a/src/index.ts b/src/index.ts index 13ed9f7..8b9fc89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export type { EngineLimits, LoadOptions, ReplyValue, + ReplyErrorMeta, RedisCallHandler, RedisHost, RedisLogHandler, diff --git a/src/types.ts b/src/types.ts index 08230f8..c166ff8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,13 +44,37 @@ * const arr: ReplyValue = [1, Buffer.from("a"), null]; * ``` */ +/** + * Machine-readable detail attached to every script-aborting error reply. The + * engine composes NO user-facing prose; it classifies the error and lets the + * host pick the wording (which for these is Redis-version-specific). + * + * - `line` (1-based script line) and `sha` (the script's SHA1, already computed + * by the engine) are always present. + * - `kind`/`name` are present only for errors the engine itself originates (the + * globals protection). `kind` is an opaque machine tag the host maps to wording; + * `name` is the variable involved. The reply's `err` carries the bare `kind`. + * Known kinds: + * - `global-read`: read of a nonexistent global. Redis >= 7.0: + * "Script attempted to access nonexistent global variable ''". + * - `global-write`: create/modify of a global. Redis >= 7.0: + * "Attempt to modify a readonly table"; Redis < 7.0: + * "Script attempted to create global variable ''". + */ +export type ReplyErrorMeta = { + kind?: string; + name?: string; + line: number; + sha: string; +}; + export type ReplyValue = | null | number | bigint | Buffer | { ok: Buffer } - | { err: Buffer; code?: Buffer } + | { err: Buffer; code?: Buffer; meta?: ReplyErrorMeta } | ReplyValue[]; /** diff --git a/test/engine.test.ts b/test/engine.test.ts index b0eedfc..db83c6b 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -194,16 +194,14 @@ test("eval: Lua error format matches Redis format", async () => { const result = engine.eval(script); assert.ok(result && typeof result === "object" && "err" in result); - const errStr = (result as { err: Buffer }).err.toString("utf8"); - - // Should match Redis format: "user_script:N: message script: , on @user_script:N." + // Lua runtime error: err is Lua's raw message (line prefix, no decoration); the + // engine adds no prose, only line/sha metadata for the host to render with. + const r = result as { err: Buffer; meta?: { line: number; sha: string } }; + const errStr = r.err.toString("utf8"); assert.ok(errStr.startsWith("user_script:1:"), `Error should start with 'user_script:1:', got: ${errStr}`); - assert.ok(errStr.includes(" script: "), `Error should contain ' script: ', got: ${errStr}`); - assert.ok(errStr.includes(", on @user_script:1."), `Error should end with ', on @user_script:1.', got: ${errStr}`); - - // SHA should be 40 hex chars - const shaMatch = errStr.match(/script: ([a-f0-9]{40}),/); - assert.ok(shaMatch, `Error should contain 40-char SHA hex, got: ${errStr}`); + assert.ok(!errStr.includes(" script: "), `Raw err should not be decorated, got: ${errStr}`); + assert.equal(r.meta?.line, 1); + assert.match(r.meta?.sha ?? "", /^[a-f0-9]{40}$/); }); test("eval: Lua error on different line includes correct line number", async () => { @@ -218,11 +216,95 @@ redis.nonexistent() -- line 4 const result = engine.eval(script); assert.ok(result && typeof result === "object" && "err" in result); - const errStr = (result as { err: Buffer }).err.toString("utf8"); + const r = result as { err: Buffer; meta?: { line: number } }; + + // Raw err references line 4; meta carries it for the host to decorate. + assert.ok(r.err.toString("utf8").startsWith("user_script:4:"), `Error should start with 'user_script:4:', got: ${r.err}`); + assert.equal(r.meta?.line, 4); +}); + +test("eval: reading a nonexistent global is classified as global-read", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + const result = engine.eval("print('a')") as { + err: Buffer; + meta?: { kind: string; name: string; line: number; sha: string }; + }; + + assert.ok(result && typeof result === "object" && "err" in result); + // Engine emits a machine kind, not wording; no marker leaks into err. + assert.ok(!result.err.toString("utf8").includes("__RLUA_E__"), `marker leaked: ${result.err}`); + assert.equal(result.meta?.kind, "global-read"); + assert.equal(result.meta?.name, "print"); + assert.equal(result.meta?.line, 1); + assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/); +}); + +test("eval: creating a global is classified as global-write", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + const result = engine.eval("x = 5") as { + err: Buffer; + code?: Buffer; + meta?: { kind: string; name: string; line: number; sha: string }; + }; + + assert.ok(result && typeof result === "object" && "err" in result); + // The engine composes no user-facing wording; it forwards a machine kind and + // the host maps it (e.g. to "Attempt to modify a readonly table"). No leak. + assert.ok(!result.err.toString("utf8").includes("__RLUA_E__"), `marker leaked: ${result.err}`); + assert.equal(result.meta?.kind, "global-write"); + assert.equal(result.meta?.name, "x"); + assert.equal(result.meta?.line, 1); + assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/); + assert.equal(result.code?.toString("utf8"), "ERR"); +}); + +test("eval: non-integer number return is truncated to integer", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + assert.equal(engine.eval("return 3.7"), 3); + assert.equal(engine.eval("return 3.3"), 3); +}); + +test("eval: script with no return value replies with nil", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + assert.equal(engine.eval("local a = 1"), null); + assert.equal(engine.eval("return"), null); +}); + +test("eval: table with both ok and err is an error (err wins)", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + const result = engine.eval("return {ok='STAT', err='ERRR'}"); + assert.ok(result && typeof result === "object" && "err" in result); + assert.equal((result as { err: Buffer }).err.toString("utf8"), "ERRR"); +}); + +test("eval: coroutine library remains available", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + assert.equal((engine.eval("return type(coroutine)") as Buffer).toString(), "table"); +}); - // Should reference line 4 - assert.ok(errStr.startsWith("user_script:4:"), `Error should start with 'user_script:4:', got: ${errStr}`); - assert.ok(errStr.includes(", on @user_script:4."), `Error should end with ', on @user_script:4.', got: ${errStr}`); +test("redis.call: boolean argument is rejected like Redis", async () => { + await resolveWasmPath(); + const module = await load(); + const engine = module.create(createTestHost()); + const result = engine.eval("return redis.call('set','k',true)"); + assert.ok(result && typeof result === "object" && "err" in result); + const errStr = (result as { err: Buffer }).err.toString("utf8"); + assert.ok( + errStr.includes("must be strings or integers"), + `got: ${errStr}`, + ); }); // ============================================================================= @@ -840,7 +922,7 @@ test("redis.call() with no args is delegated to the host", async () => { assert.ok(result && typeof result === "object" && "err" in result); }); -test("redis.call command error is decorated and code is split out", async () => { +test("redis.call command error passes through with code and line/sha metadata", async () => { await resolveWasmPath(); const module = await load(); const engine = module.create( @@ -854,14 +936,22 @@ test("redis.call command error is decorated and code is split out", async () => }), ); - const result = engine.eval("return redis.call('GET', 'k')") as { err: Buffer; code?: Buffer }; + const result = engine.eval("return redis.call('GET', 'k')") as { + err: Buffer; + code?: Buffer; + meta?: { line: number; sha: string; kind?: string }; + }; assert.ok(result && typeof result === "object" && "err" in result); - // Command errors have no user_script prefix -> reported at line 1, code preserved. - assert.match( + // Command errors already carry their message + code; the engine passes them + // through undecorated (no kind), attaching only line/sha for the host. + assert.equal( result.err.toString("utf8"), - /^Operation against a key holding the wrong kind of value script: [a-f0-9]{40}, on @user_script:1\.$/, + "Operation against a key holding the wrong kind of value", ); assert.equal(result.code?.toString("utf8"), "WRONGTYPE"); + assert.equal(result.meta?.line, 1); + assert.match(result.meta?.sha ?? "", /^[a-f0-9]{40}$/); + assert.equal(result.meta?.kind, undefined); }); test("redis.pcall error value returned by the script is not decorated", async () => { diff --git a/wasm/src/redis_api.c b/wasm/src/redis_api.c index 3160dd3..38a6b44 100644 --- a/wasm/src/redis_api.c +++ b/wasm/src/redis_api.c @@ -99,19 +99,12 @@ static int arg_to_bytes(lua_State *L, int idx, const char **out, size_t *len) { switch (type) { case LUA_TSTRING: case LUA_TNUMBER: { + // Real Redis accepts only strings and numbers as command arguments; + // numbers are stringified (e.g. 3.3 -> "3.3"). Booleans, nil and tables + // are rejected by the caller. *out = lua_tolstring(L, idx, len); return 0; } - case LUA_TBOOLEAN: { - if (lua_toboolean(L, idx)) { - *out = "1"; - *len = 1; - } else { - *out = "0"; - *len = 1; - } - return 0; - } default: return -1; } @@ -222,7 +215,9 @@ static int redis_call_common(lua_State *L, int raise_on_error) { ArgBuffer ab; if (encode_args(L, 1, argc, &ab) != 0) { free(ab.data); - return luaL_error(L, "ERR invalid argument to redis.call"); + // Real Redis raises this without a "user_script:N:" position prefix. + lua_pushliteral(L, "Lua redis lib command arguments must be strings or integers"); + return lua_error(L); } PtrLen reply = raise_on_error ? host_redis_call((uint32_t)(uintptr_t)ab.data, (uint32_t)ab.len) : host_redis_pcall((uint32_t)(uintptr_t)ab.data, (uint32_t)ab.len); @@ -272,6 +267,15 @@ static int l_redis_sha1hex(lua_State *L) { static int l_redis_error_reply(lua_State *L) { size_t len = 0; const char *msg = luaL_checklstring(L, 1, &len); + // Real Redis prepends the default "ERR " code when the message carries no code + // of its own. The code is the leading token, so its absence is signalled by + // there being no space in the message. + if (memchr(msg, ' ', len) == NULL) { + lua_pushliteral(L, "ERR "); + lua_pushvalue(L, 1); + lua_concat(L, 2); + msg = lua_tolstring(L, -1, &len); + } return push_error_table(L, (const uint8_t *)msg, (uint32_t)len); } diff --git a/wasm/src/runtime.c b/wasm/src/runtime.c index a8e499b..6cbbea4 100644 --- a/wasm/src/runtime.c +++ b/wasm/src/runtime.c @@ -129,6 +129,17 @@ static PtrLen reply_script_error(const char *msg, size_t len) { return out; } +static PtrLen reply_null(void) { + ReplyBuffer rb; + rb_init(&rb); + if (rb_write_header(&rb, REPLY_NULL, 0) != 0) { + return (PtrLen){0, 0}; + } + PtrLen out = rb_finalize(&rb); + free(rb.data); + return out; +} + static PtrLen reply_status(const char *msg, size_t len) { ReplyBuffer rb; rb_init(&rb); @@ -148,39 +159,44 @@ static int encode_lua_value(lua_State *L, int idx, ReplyBuffer *rb); static int encode_table(lua_State *L, int idx, ReplyBuffer *rb) { size_t len = 0; - int has_ok = 0; - int has_err = 0; const char *msg = NULL; - lua_getfield(L, idx, "ok"); + // Redis checks `err` before `ok`: a table carrying both fields is an error. + lua_getfield(L, idx, "err"); if (lua_isstring(L, -1)) { msg = lua_tolstring(L, -1, &len); - has_ok = 1; + int rc = rb_write_header(rb, REPLY_ERROR, (uint32_t)len); + if (rc == 0) { + rc = rb_append(rb, msg, len); + } + lua_pop(L, 1); + return rc; } lua_pop(L, 1); - lua_getfield(L, idx, "err"); - if (!has_ok && lua_isstring(L, -1)) { + lua_getfield(L, idx, "ok"); + if (lua_isstring(L, -1)) { msg = lua_tolstring(L, -1, &len); - has_err = 1; - } - lua_pop(L, 1); - - if (has_ok) { - if (rb_write_header(rb, REPLY_STATUS, (uint32_t)len) != 0) { - return -1; + int rc = rb_write_header(rb, REPLY_STATUS, (uint32_t)len); + if (rc == 0) { + rc = rb_append(rb, msg, len); } - return rb_append(rb, msg, len); + lua_pop(L, 1); + return rc; } + lua_pop(L, 1); - if (has_err) { - if (rb_write_header(rb, REPLY_ERROR, (uint32_t)len) != 0) { - return -1; + // Array reply: iterate from index 1 and stop at the first nil, like Redis. + size_t count = 0; + for (;;) { + lua_rawgeti(L, idx, (int)count + 1); + int is_nil = lua_isnil(L, -1); + lua_pop(L, 1); + if (is_nil) { + break; } - return rb_append(rb, msg, len); + count++; } - - size_t count = lua_objlen(L, idx); if (rb_write_header(rb, REPLY_ARRAY, (uint32_t)count) != 0) { return -1; } @@ -201,25 +217,15 @@ static int encode_lua_value(lua_State *L, int idx, ReplyBuffer *rb) { case LUA_TNIL: return rb_write_header(rb, REPLY_NULL, 0); case LUA_TNUMBER: { + // Real Redis converts a Lua number return value to an integer reply, + // truncating any fractional part (e.g. `return 3.7` -> 3). lua_Number num = lua_tonumber(L, idx); - lua_Number int_part = (lua_Number)(int64_t)num; - if (num == int_part) { - if (rb_write_header(rb, REPLY_INT, 8) != 0) { - return -1; - } - uint8_t payload[8]; - write_i64_le(payload, (int64_t)num); - return rb_append(rb, payload, sizeof(payload)); - } - size_t len = 0; - const char *str = lua_tolstring(L, idx, &len); - if (!str) { - return -1; - } - if (rb_write_header(rb, REPLY_BULK, (uint32_t)len) != 0) { + if (rb_write_header(rb, REPLY_INT, 8) != 0) { return -1; } - return rb_append(rb, str, len); + uint8_t payload[8]; + write_i64_le(payload, (int64_t)num); + return rb_append(rb, payload, sizeof(payload)); } case LUA_TBOOLEAN: if (lua_toboolean(L, idx)) { @@ -276,6 +282,13 @@ static void disable_non_determinism(lua_State *L) { remove_global(L, "require"); remove_global(L, "dofile"); remove_global(L, "loadfile"); + // Base-lib globals real Redis does not expose to scripts. With globals + // protection installed, accessing these raises the nonexistent-global error. + remove_global(L, "print"); + remove_global(L, "loadstring"); + remove_global(L, "collectgarbage"); + remove_global(L, "gcinfo"); + remove_global(L, "newproxy"); remove_package_entry(L, "io"); remove_package_entry(L, "os"); remove_package_entry(L, "debug"); @@ -290,6 +303,46 @@ static void disable_non_determinism(lua_State *L) { lua_pop(L, 1); } +// Globals protection: mirror real Redis. Reading a global that does not exist, +// or creating a new one from a script, raises an error instead of silently +// returning nil / mutating the shared environment. +// +// Both cases emit a coded marker `__RLUA_E__::` rather than a +// user-facing string: the wording is Redis-version-specific and is the host's +// to choose. The TS layer forwards { kind, name } to the host; it composes no +// prose. `name` is the global at __index/__newindex key (stack index 2). +static int protect_globals_index(lua_State *L) { + const char *name = lua_tostring(L, 2); + return luaL_error(L, "__RLUA_E__:global-read:%s", name ? name : "?"); +} + +static int protect_globals_newindex(lua_State *L) { + const char *name = lua_tostring(L, 2); + return luaL_error(L, "__RLUA_E__:global-write:%s", name ? name : "?"); +} + +static void enable_globals_protection(lua_State *L) { + lua_pushvalue(L, LUA_GLOBALSINDEX); + lua_newtable(L); + lua_pushcfunction(L, protect_globals_index); + lua_setfield(L, -2, "__index"); + lua_pushcfunction(L, protect_globals_newindex); + lua_setfield(L, -2, "__newindex"); + lua_setmetatable(L, -2); + lua_pop(L, 1); +} + +// Set a global by raw assignment, bypassing the protection metatable above. +// Value to assign must be on top of the stack; it is popped. +static void raw_setglobal(lua_State *L, const char *name) { + lua_pushvalue(L, LUA_GLOBALSINDEX); // [.., value, G] + lua_insert(L, -2); // [.., G, value] + lua_pushstring(L, name); // [.., G, value, name] + lua_insert(L, -2); // [.., G, name, value] + lua_rawset(L, -3); // G[name] = value; pops name, value -> [.., G] + lua_pop(L, 1); // [..] +} + static void luaLoadLib(lua_State *L, const char *name, lua_CFunction func) { lua_pushcfunction(L, func); lua_pushstring(L, name); @@ -309,14 +362,14 @@ static void load_redis_modules(lua_State *L) { } static void open_allowed_libs(lua_State *L) { + // luaopen_base pushes TWO tables (the globals table and the coroutine table); + // the rest push one. Clear the stack afterwards so no library table is left + // behind to masquerade as a script return value. luaopen_base(L); - lua_pop(L, 1); luaopen_table(L); - lua_pop(L, 1); luaopen_string(L); - lua_pop(L, 1); luaopen_math(L); - lua_pop(L, 1); + lua_settop(L, 0); disable_non_determinism(L); load_redis_modules(L); } @@ -375,16 +428,16 @@ static int set_keys_argv(lua_State *L, const uint8_t *buf, size_t len, uint32_t offset += item_len; } - lua_setglobal(L, "ARGV"); - lua_setglobal(L, "KEYS"); + raw_setglobal(L, "ARGV"); + raw_setglobal(L, "KEYS"); return 0; } static void set_empty_keys_argv(lua_State *L) { lua_createtable(L, 0, 0); - lua_setglobal(L, "KEYS"); + raw_setglobal(L, "KEYS"); lua_createtable(L, 0, 0); - lua_setglobal(L, "ARGV"); + raw_setglobal(L, "ARGV"); } int32_t init(void) { @@ -398,6 +451,7 @@ int32_t init(void) { } open_allowed_libs(g_state); register_redis_api(g_state); + enable_globals_protection(g_state); lua_sethook(g_state, fuel_hook, LUA_MASKCOUNT, FUEL_HOOK_STEP); reset_fuel(); return 0; @@ -414,6 +468,7 @@ int32_t reset(void) { } open_allowed_libs(g_state); register_redis_api(g_state); + enable_globals_protection(g_state); lua_sethook(g_state, fuel_hook, LUA_MASKCOUNT, FUEL_HOOK_STEP); reset_fuel(); return 0; @@ -442,7 +497,8 @@ PtrLen eval(uint32_t ptr, uint32_t len) { } int top = lua_gettop(g_state); if (top == 0) { - return reply_status("OK", 2); + // A script with no return value replies with nil, matching real Redis. + return reply_null(); } ReplyBuffer rb; rb_init(&rb); @@ -496,7 +552,8 @@ PtrLen eval_with_args(uint32_t script_ptr, uint32_t script_len, uint32_t args_pt } int top = lua_gettop(g_state); if (top == 0) { - return reply_status("OK", 2); + // A script with no return value replies with nil, matching real Redis. + return reply_null(); } ReplyBuffer rb; rb_init(&rb);