Skip to content

Commit 7bf3e86

Browse files
fatal10110claude
andauthored
feat(compat): version compatibility profiles for the Lua sandbox (#25)
* feat(compat): version compatibility profiles for the Lua sandbox Emulate the Lua sandbox behavior of a chosen Redis/Valkey version. Only three behaviors actually differ across Redis 6.2-8.x and Valkey: - `print` global -> present only in Redis 6.2 - `os` library -> Redis 7.4+ / Valkey 8.0+ - `server` alias -> Valkey 8.0+ only (Redis keeps `redis`) Modeled as three orthogonal flags (COMPAT_PRINT/OS/SERVER_ALIAS) passed as a u8 bitmask via a new `set_compat()` WASM export, consumed by a shared `setup_state()` (init/reset deduped). TS exposes `load({ profile, compat })` where `profile` is a named preset over the version matrix and `compat` overrides individual flags. Default (no profile) preserves prior behavior (os + server alias, no print; ~ valkey 8.0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(compat): rename profiles to hyphenated names Drop valkey-7.2 and valkey-8.1; add valkey-9.0. Profile set is now redis-6.2, redis-7.0, redis-7.2, redis-7.4, redis-8.0, valkey-8.0, valkey-9.0. valkey-9.0 mirrors valkey-8.0 (no known Lua sandbox delta). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b265ab4 commit 7bf3e86

8 files changed

Lines changed: 217 additions & 46 deletions

File tree

src/engine.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import type {
5959
StandaloneOptions,
6060
RedisProp,
6161
RedisProps,
62+
CompatProfile,
63+
CompatOverrides,
6264
} from "./types.js";
6365
import {
6466
decodeReply,
@@ -473,6 +475,44 @@ type MutableHandlers = {
473475
* const standalone = module.createStandalone();
474476
* ```
475477
*/
478+
// Compatibility flag bits — must match the COMPAT_* macros in wasm/src/runtime.c.
479+
const COMPAT_PRINT = 0x1;
480+
const COMPAT_OS = 0x2;
481+
const COMPAT_SERVER_ALIAS = 0x4;
482+
483+
/**
484+
* Profile presets -> the three Lua sandbox behavior flags. Mirrors the
485+
* Redis/Valkey version matrix (see redis-version-lua-behavior-matrix).
486+
*/
487+
const COMPAT_PROFILES: Record<CompatProfile, Required<CompatOverrides>> = {
488+
"redis-6.2": { print: true, os: false, serverAlias: false },
489+
"redis-7.0": { print: false, os: false, serverAlias: false },
490+
"redis-7.2": { print: false, os: false, serverAlias: false },
491+
"redis-7.4": { print: false, os: true, serverAlias: false },
492+
"redis-8.0": { print: false, os: true, serverAlias: false },
493+
"valkey-8.0": { print: false, os: true, serverAlias: true },
494+
"valkey-9.0": { print: false, os: true, serverAlias: true },
495+
};
496+
497+
// Default when no profile is given: preserve the historical behavior (≈ valkey-8.0).
498+
const COMPAT_DEFAULT: Required<CompatOverrides> = COMPAT_PROFILES["valkey-8.0"];
499+
500+
/** Resolve a profile + per-flag overrides to the u8 bitmask the WASM expects. */
501+
function resolveCompatFlags(
502+
profile?: CompatProfile,
503+
overrides?: CompatOverrides,
504+
): number {
505+
const merged = {
506+
...(profile ? COMPAT_PROFILES[profile] : COMPAT_DEFAULT),
507+
...overrides,
508+
};
509+
return (
510+
(merged.print ? COMPAT_PRINT : 0) |
511+
(merged.os ? COMPAT_OS : 0) |
512+
(merged.serverAlias ? COMPAT_SERVER_ALIAS : 0)
513+
);
514+
}
515+
476516
export class LuaWasmModule {
477517
private consumed = false;
478518

@@ -583,6 +623,12 @@ export class LuaWasmModule {
583623
);
584624
}
585625

626+
if (this.exports._set_compat) {
627+
this.exports._set_compat(
628+
resolveCompatFlags(this.options.profile, this.options.compat),
629+
);
630+
}
631+
586632
const initResult = this.exports._init();
587633
if (typeof initResult === "number" && initResult !== 0) {
588634
throw new Error("Failed to initialize Lua WASM engine");
@@ -792,4 +838,6 @@ export type {
792838
LoadOptions,
793839
RedisProp,
794840
RedisProps,
841+
CompatProfile,
842+
CompatOverrides,
795843
};

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export type {
1010
RedisLogHandler,
1111
StandaloneOptions,
1212
RedisProp,
13-
RedisProps
13+
RedisProps,
14+
CompatProfile,
15+
CompatOverrides
1416
} from "./types.js";
1517
import { encodeReplyValue, decodeReply, encodeArgArray } from "./codec.js";
1618
import type { ReplyValue as ReplyValueType } from "./types.js";

src/loader-core.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ export type WasmExports = {
7171
*/
7272
_set_limits?: (maxFuel: number, maxReplyBytes: number, maxArgBytes: number) => void;
7373

74+
/**
75+
* Select the compatibility profile (which Redis/Valkey version's Lua sandbox
76+
* behavior to emulate). Bitmask: 0x1 keep `print`, 0x2 expose `os`, 0x4
77+
* `server` alias. Call before _init/_reset.
78+
*/
79+
_set_compat?: (flags: number) => void;
80+
7481
/**
7582
* Allocate memory in WASM linear memory.
7683
* @param size - Number of bytes to allocate

src/types.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,35 @@ export type EngineLimits = {
240240
maxArgBytes?: number;
241241
};
242242

243+
/**
244+
* Named Redis/Valkey compatibility profile. Selects which of the three Lua
245+
* sandbox behaviors that differ across versions are emulated. Aliases collapse
246+
* to identical behavior (redis-7.0 == redis-7.2; redis-7.4 == redis-8.0;
247+
* valkey-8.0 == valkey-9.0). Use {@link CompatOverrides} to tweak a single flag.
248+
*/
249+
export type CompatProfile =
250+
| "redis-6.2"
251+
| "redis-7.0"
252+
| "redis-7.2"
253+
| "redis-7.4"
254+
| "redis-8.0"
255+
| "valkey-8.0"
256+
| "valkey-9.0";
257+
258+
/**
259+
* Fine-grained overrides for the compatibility profile, merged over the
260+
* selected {@link CompatProfile} (or the default). These are the only three Lua
261+
* sandbox behaviors that actually differ across Redis 6.2-8.x and Valkey.
262+
*/
263+
export type CompatOverrides = {
264+
/** Keep the Lua `print` global. Only Redis 6.2 did. Default: false. */
265+
print?: boolean;
266+
/** Expose the (sandboxed) `os` library. Redis 7.4+ / Valkey 8.0+. Default: true. */
267+
os?: boolean;
268+
/** Expose `server` as an alias of `redis`. Valkey 8.0+ only. Default: true. */
269+
serverAlias?: boolean;
270+
};
271+
243272
/**
244273
* Configuration options for creating a LuaWasmEngine with host integration.
245274
*
@@ -275,6 +304,12 @@ export type EngineOptions = {
275304

276305
/** Optional host-injected `redis.*` props (constants and simple stubs). */
277306
redisProps?: RedisProps;
307+
308+
/** Redis/Valkey version whose Lua sandbox behavior to emulate. Default: ≈ valkey-8.0. */
309+
profile?: CompatProfile;
310+
311+
/** Per-flag compatibility overrides, merged over `profile` (or the default). */
312+
compat?: CompatOverrides;
278313
};
279314

280315
/**
@@ -308,6 +343,12 @@ export type StandaloneOptions = {
308343

309344
/** Optional host-injected `redis.*` props (constants and simple stubs). */
310345
redisProps?: RedisProps;
346+
347+
/** Redis/Valkey version whose Lua sandbox behavior to emulate. Default: ≈ valkey-8.0. */
348+
profile?: CompatProfile;
349+
350+
/** Per-flag compatibility overrides, merged over `profile` (or the default). */
351+
compat?: CompatOverrides;
311352
};
312353

313354
/**
@@ -340,4 +381,10 @@ export type LoadOptions = {
340381

341382
/** Optional host-injected `redis.*` props (constants and simple stubs). */
342383
redisProps?: RedisProps;
384+
385+
/** Redis/Valkey version whose Lua sandbox behavior to emulate. Default: ≈ valkey-8.0. */
386+
profile?: CompatProfile;
387+
388+
/** Per-flag compatibility overrides, merged over `profile` (or the default). */
389+
compat?: CompatOverrides;
343390
};

test/engine.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import fs from "node:fs/promises";
66
import path from "node:path";
77
import test from "node:test";
88
import assert from "node:assert/strict";
9-
import { load, LuaWasmModule } from "../src/index.js";
9+
import { load, LuaWasmModule, LuaEngine } from "../src/index.js";
1010
import { LuaWasmEngine, makePropsHandler } from "../src/engine.js";
1111
import { encodeRedisProps } from "../src/codec.js";
1212
import type { ReplyValue, RedisHost } from "../src/types.js";
@@ -1341,3 +1341,65 @@ test("redisProps integration: makes injected props readonly under globals protec
13411341
const r = engine.eval("redis.REDIS_VERSION = 'x' return 1") as { err: Buffer };
13421342
assert.ok(r && typeof r === "object" && "err" in r);
13431343
});
1344+
1345+
// =============================================================================
1346+
// Compatibility profiles (print / os / server alias differ across versions)
1347+
// =============================================================================
1348+
1349+
type GlobalReadErr = { err: Buffer; meta?: { kind: string; name: string } };
1350+
1351+
/** Assert reading `name` raises a global-read error (i.e. the global is absent). */
1352+
function assertGlobalAbsent(engine: LuaEngine, name: string): void {
1353+
const r = engine.eval(`return ${name}`) as GlobalReadErr;
1354+
assert.ok(r && typeof r === "object" && "err" in r, `${name} should be absent`);
1355+
assert.equal(r.meta?.kind, "global-read");
1356+
assert.equal(r.meta?.name, name);
1357+
}
1358+
1359+
test("compat redis6.2: print kept, os absent, server absent", async () => {
1360+
const module = await load({ profile: "redis-6.2" });
1361+
const engine = module.create(createTestHost());
1362+
assert.equal(engine.eval("return type(print)").toString(), "function");
1363+
assert.equal(engine.eval("print('x') return 1"), 1); // print runs, returns nil
1364+
assertGlobalAbsent(engine, "os");
1365+
assertGlobalAbsent(engine, "server");
1366+
});
1367+
1368+
test("compat redis7.2: print absent, os absent, server absent", async () => {
1369+
const module = await load({ profile: "redis-7.2" });
1370+
const engine = module.create(createTestHost());
1371+
assertGlobalAbsent(engine, "print");
1372+
assertGlobalAbsent(engine, "os");
1373+
assertGlobalAbsent(engine, "server");
1374+
});
1375+
1376+
test("compat redis8.0: os present, server absent (redis has no server alias)", async () => {
1377+
const module = await load({ profile: "redis-8.0" });
1378+
const engine = module.create(createTestHost());
1379+
assert.equal(engine.eval("return type(os)").toString(), "table");
1380+
assertGlobalAbsent(engine, "print");
1381+
assertGlobalAbsent(engine, "server");
1382+
});
1383+
1384+
test("compat valkey8.0: os present, server aliases redis", async () => {
1385+
const module = await load({ profile: "valkey-8.0" });
1386+
const engine = module.create(createTestHost());
1387+
assert.equal(engine.eval("return type(os)").toString(), "table");
1388+
assert.equal(engine.eval("return server == redis"), 1);
1389+
assertGlobalAbsent(engine, "print");
1390+
});
1391+
1392+
test("compat: per-flag override is merged over the profile", async () => {
1393+
const module = await load({ profile: "redis-7.2", compat: { os: true } });
1394+
const engine = module.create(createTestHost());
1395+
assert.equal(engine.eval("return type(os)").toString(), "table");
1396+
assertGlobalAbsent(engine, "server"); // unchanged by the override
1397+
});
1398+
1399+
test("compat: default (no profile) keeps historical behavior", async () => {
1400+
const module = await load();
1401+
const engine = module.create(createTestHost());
1402+
assert.equal(engine.eval("return type(os)").toString(), "table");
1403+
assert.equal(engine.eval("return server == redis"), 1);
1404+
assertGlobalAbsent(engine, "print");
1405+
});

wasm/build/build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ emcc -O2 -DENABLE_CJSON_GLOBAL \
4444
-sEXPORTED_RUNTIME_METHODS="['HEAPU8']" \
4545
-sINCOMING_MODULE_JS_API="['locateFile','instantiateWasm']" \
4646
-sINITIAL_MEMORY=67108864 -sMAXIMUM_MEMORY=67108864 \
47-
-sEXPORTED_FUNCTIONS="['_init','_reset','_eval','_eval_with_args','_alloc','_free_mem','_set_limits']" \
47+
-sEXPORTED_FUNCTIONS="['_init','_reset','_eval','_eval_with_args','_alloc','_free_mem','_set_limits','_set_compat']" \
4848
-I"$ROOT_DIR/wasm/include" -I"$LUA_SRC_DIR" -I"$REDIS_LUA_DEPS" -I"$REDIS_SRC" \
4949
"$SRC_DIR/runtime.c" "$SRC_DIR/redis_api.c" $CORE_FILES $LIB_FILES $MODULE_FILES \
5050
-o "$OUT_DIR/redis_lua.mjs"

wasm/include/abi.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ PtrLen eval(uint32_t ptr, uint32_t len);
6363
PtrLen eval_with_args(uint32_t script_ptr, uint32_t script_len, uint32_t args_ptr,
6464
uint32_t args_len, uint32_t keys_count);
6565
void set_limits(uint32_t max_fuel, uint32_t max_reply_bytes, uint32_t max_arg_bytes);
66+
void set_compat(uint32_t flags);
6667
uint32_t alloc(uint32_t size);
6768
void free_mem(uint32_t ptr);
6869

0 commit comments

Comments
 (0)