Repository navigation
Releases
0.9.1
Version 0.9.1
Dependencies
- No runtime dependency left:
sprintf-js(an advisory with no fixed release: unbounded precision),tmpandreadline-syncare replaced by built-in code.src/cformat.jsformats%e,%f,%gand%sforstring.format,os.tmpnamebuilds its name from the temp directory andcrypto, anddebug.debugreads stdin withfs.readSync(end of input acts ascont). The browser map ofpackage.jsonnow stubscryptoinstead oftmpandreadline-sync. string.formatfollows C more closely: exact ties round to even (%.0fof 2.5 is2), exponents have two digits at least (1e+00),-0keeps its sign in%g, and%0eof zero pads correctly. Checked againstprintfon about 19,500 random formats, and every legal precision (up to 99) runs without a host exception,%.99gand%.dincluded;test/cformat.test.jshas 62 cases.- Dev dependencies: eslint 10, espree 11, jest 30.5, playwright-core 1.64.1, and
globalsdeclared (eslint.config.mjsimports it).yarn auditstill listssprintf-js1.0.3 through jest (development only, no fix published).
0.9.0
Version 0.9.0
Performance
- String buffers and copies:
luaL_addcharno longer builds a view per character, andluastring_fromcopies typed arrays withnew Uint8Array(3-4x faster thanUint8Array.fromon short strings). - Real-world check, World Conquest II (the campaign's own Lua from Wesnoth 1.19, run by the
mwg-wesnothport, map-generation scenario, same seed, same output). Measured in one session: Fengari 0.1.5 4.7 s, Pyroeis 0.5.5 5.0 s, then 2.2 s with the integer,for-loop and buffer work. Timings drift with machine load: measured side by side, this change alone is 5 to 10% faster than 0.8.0 on that run (about 2.4 s against 2.5 to 2.7 s). The fivemwg-wesnothLua tests that can run without generated data pass on all three.
Security (limits)
limits.coroutines: a script could create a million coroutines (about 1.7 GiB of heap, in 3.5 s) under a generous instruction budget, becauseallocationandtotalAllocationonly see strings and buffers. The new limit caps the coroutines created per top-level call (coroutine.create,coroutine.wrap, and host coroutines made during the call); the next one raises aLimitError(limit: "coroutines") thatpcallcannot catch. It counts creations, not live coroutines, since the garbage collector is not observable, so a persistent VM can still keep 10,000 new ones per call. 10,000 costs about 17 MiB. Opt-in: without it nothing is counted.limits.tablesandlimits.closures: the same mechanism for tables (3 million empty tables: about 2.2 GiB) and Lua closures. Each counts creations per top-level call, raises an uncatchableLimitError(limit: "tables"/"closures") and is opt-in. Closures are counted when actually created, so an upvalue-free function reused from the cache is not recounted.LimitErroralways has a message: a limit raised in the middle of an instruction (table or closure creation) could reach the host with an empty message; it is now<limit> limit exceeded(thememorylimit keepsnot enough memory, as the script sees it).LimitError#limitis one ofinstructions,time,memory,coroutines,tables,closures.- Persistent VMs: the creation counters do not see live objects (the garbage collector is not observable), so a VM kept across calls can accumulate up to the limit per call. Discard it, or run untrusted scripts on a fresh VM per script.
- Tests:
test/pyroeis-vm-limits.test.js.
Host API (untrusted scripts)
sandboxmakes the host's own chunks text-only:run,runMultiandcoroutineFromSourcenow load in text mode whensandbox: true, like the script'sloadalready did, so a binary chunk never reaches the undumper from either side.pushJson/readJson: a strict JSON exchange for hosts that run untrusted scripts.readJsonrefuses functions, threads, userdata, non-UTF-8 strings and exotic keys, creates no Lua reference (readanchors every function in the registry), never returns BigInt, Map or Uint8Array, bounds nesting (maxDepth, 20 by default) and keeps__proto__an ordinary key; tables become plain objects, or arrays with{ arrays: true }.pushJsonpushes numbers as Lua floats and refuses anything that is not JSON, wherepushconverts it.
Documentation
- Security page: the demonstration uses
coroutines: 10000, with a new case (coroutines) comparing it with upstream Fengari under the same instruction budget.
Pyroeis 0.8.0
Security (limits)
limits.coroutines: a script could create a million coroutines (about 1.7 GiB of heap, in 3.5 s) under a generous instruction budget, becauseallocationandtotalAllocationonly see strings and buffers. The new limit caps the coroutines created per top-level call (coroutine.create,coroutine.wrap, and host coroutines made during the call); the next one raises aLimitError(limit: "coroutines") thatpcallcannot catch. It counts creations, not live coroutines, since the garbage collector is not observable, so a persistent VM can still keep that many new ones per call. 10,000 costs about 17 MiB. Opt-in: without it nothing is counted.- Not covered: tables and closures cost as much (3 million empty tables: about 2.2 GiB); only the instruction and time budgets bound them.
Security page
- Interop cases run on Fengari: the
js.global, metatable and__proto__cases load fengari-interop 0.1.4 on the upstream side, as an application exchanging data with JavaScript would; Pyroeis has no such module. - A verdict per case: a green tick when the engine contains the script, a red cross when it ran on or got through, with wording specific to each case (forged chunk accepted, bridge reached, JavaScript run, prototype polluted) instead of "normal return".
- Cases where both engines answer alike were removed (
jsglobal,proto,interop,eval); theevalrow stays in the matrix as a decision, not a runnable case. - New case
coroutinescompares the cap with upstream Fengari under the same instruction budget. The Pyroeis version is named wherever Fengari 0.1.5 is.
Publishing
npm publication runs through the release workflow as a staged publish; a maintainer approves it with npm stage approve.
Pyroeis 0.7.0
Security (binary chunks)
- A forged section size could still cost gigabytes: the loader allocated every string (and every header literal) at the size the chunk declared, before reading a single byte, so 30 bytes of input asking for a 2 GB string forced the allocation first and refused it after.
readnow doubles its buffer as the bytes arrive, charging each real allocation to the allocator hook; a declared size the input cannot back is refused astruncated, with a peak of a few kilobytes. Section counts remain bounded by the input itself. The fuzz suite (60,000 corrupted chunks) still passes; a new test intest/lverify.test.jsloads a chunk whose long-string constant declares 2 GB and checks both the refusal and the allocation peak throughlua_setallocf.
Security page
- A test matrix checked against reality: the nine attack cases sketched in the working notes (corrupted chunks, infinite loop, malicious metatable, string concatenation, giant table,
js.globalescape, prototype injection, constant mentioningeval, and a checksum that Lua chunks do not have) now appear as a static table on the security page with the behaviour both engines really have and a way to verify each row. Three rows were corrected rather than copied: the chunk format carries no checksum,t.__proto__written from Lua is an ordinary key, andjs.globalexists only with fengari-interop; the constant filter was set aside. Case links such as#allocationselect their reproduction, so every comparison stays runnable in the browser.
Contributors
- The README licence line names the people: « MIT © Michel Weinachter (Datamoc), Benoit Giannangeli, Daurnimator, and contributors. », each name linked to the GitHub profile that commits the repository history.
LICENSEkeeps the plain MIT text.
Publishing
npm publication runs through the release workflow as a staged publish; a maintainer approves it with npm stage approve.
v0.6.0 - the last Fengari names are gone (breaking)
Breaking: the last Fengari names are gone
PYROEISCONFreplacesFENGARICONFas the environment variable that configures the VM ({"LUAI_MAXSTACK":100000}...).FENGARICONFis no longer read.- The Lua library
fengariis nowpyroeis(pyroeis.VERSION,RELEASE,COPYRIGHT,AUTHORS...).libs: ['pyroeis']selects it;sandbox: truestill leaves it out. FENGARI_*constants arePYROEIS_*and describe Pyroeis (Pyroeis 0.6.0). The copyright keeps the Fengari and Lua attributions.luaopen_fengariisluaopen_pyroeis;LUA_FENGARILIBNAMEisLUA_PYROEISLIBNAME.- The browser bundle no longer exposes the
Fengarialias; usePyroeis.
Also included since 0.5.5: the CodeQL Action moves to v4, and the performance work merged in #1.
Full details in CHANGES.md.
🤖 Generated with Claude Code
v0.5.5
Security: binary chunks and the translator
- A corrupted binary chunk could stall the VM inside a single instruction, out of reach of any count hook. The loader now verifies registers, constants, upvalues, prototypes and jump targets (
src/lverify.js) and refuses a bad chunk withmalformed (...). JavaScript errors raised while reading a chunk become a load error instead of a silent status-1. A binary chunk remains trusted input;sandbox: truestill refuses them all. lua2jswarnings can beunsafewhen the translation reaches the JavaScript environment (eval,Function,globalThis...) or a prototype (__proto__,constructor). A__proto__key in a table constructor is now an own property. The Lua ↔ JavaScript page refuses to run such a translation.
Documentation
- The security page explains why untrusted game scripts get an exchange mode instead of
fengari-interop, and replays 1,500 corrupted binary chunks on upstream Fengari 0.1.5 (72 JavaScript exceptions crosspcall) and on the Pyroeis sandbox (none).
Full details in CHANGES.md.
🤖 Generated with Claude Code
v0.5.4 - security: limit overruns cannot be hidden, budgeted lookups and close; lua2js keeps Lua truth
Security (execution limits) - do not use 0.4.x to 0.5.3 for untrusted scripts with limits:
- A limit overrun could be hidden by the script: a coroutine created by Lua that exceeded the budget made
coroutine.resume/pcallreturn normally andvm.run()returned its value. Every host entry point (run,call,callRef,Coroutine#resume,protect) now fails with aLimitErrorwhenever a limit was hit during the call, even if Lua caught it. Coroutine#close()ran its__closehandlers without a budget; it now gets a fresh one.- Global path lookups (
call,callMulti,compile,coroutine) ran before the budget, so an__indexmetamethod on_Gcould loop forever. They now run in a protected call under the limits. sandbox: trueno longer leaves the__randomhelper readable as a global.
lua2js: a or b was translated to a ?? b, which returns false where Lua returns b. It now keeps Lua's meaning; the old form is the nullishOr option (--nullish-or).
New: print option ('buffer' | 'native' | false) so a host no longer re-opens libraries to get Lua's own print (which restored string.dump after the sandbox removed it); LuaRef.type is typed 'function' | 'thread' | 'userdata'; vm.resolvePath, vm.failIfLimitHit declared.
Docs: compile is intentionally disabled while limits are active (see the README).
See CHANGES.md for details.
v0.5.3 - bundle for the browser without shims
Fixes bundling for the browser. luaconf.js called require('os').platform() whenever process existed, and a bundler's process shim also made ldblib.js crash at load on an empty readline-sync stub, so consumers had to write a local shim.
- Node detection goes through one module,
src/isnode.js(process.versions.node), instead oftypeof processin 13 places;os.platform()becameprocess.platform. package.jsonhas abrowserfield mappingfs,os,path,tmp,child_processandreadline-syncto empty modules, so webpack, esbuild, Vite and Browserify bundle the package without shims orresolve.fallback.test/browser-compat.test.jsfails if the library requires a Node-only module on the browser path.
See CHANGES.md for details.
v0.5.2 - declare limitHit, pushPath, classifyKeys, nextRandom
TypeScript: FengariVm now declares the host-facing members limitHit, pushPath(L, path), classifyKeys(L, table) and nextRandom(). No runtime change. See CHANGES.md.
v0.5.1 - TypeScript declarations, vm.protect, lua2js comment fix
Security fix (lua2js): a Lua line comment containing a bare CR, U+2028 or U+2029 ended the generated // comment early, so the rest of the comment ran as JavaScript. Those characters are now replaced by spaces. If you translated untrusted Lua with 0.5.0, review the comments in the output.
New:
- TypeScript declarations (
src/fengari.d.ts,"types"inpackage.json) forFengariVm,LuaRef,LuaError,LimitError,Coroutine,Userdata,LuaFloat, the options and thelua2jsresult. The C-API namespaces are typedanyfor now. vm.throwLuaError(L, index = -1): throws the Lua error value atindexas aLuaError(LimitErrorafter a limit overrun).vm.protect(fn): runs a host callback in a Lua protected call, so Lua errors raised through the C API becomeLuaError/LimitErrorand the stack is restored.
See CHANGES.md for details.