Skip to content

Releases

0.9.1

Choose a tag to compare

@datamoc datamoc released this 17 Oct 00:29

Version 0.9.1

Dependencies

  • No runtime dependency left: sprintf-js (an advisory with no fixed release: unbounded precision), tmp and readline-sync are replaced by built-in code. src/cformat.js formats %e, %f, %g and %s for string.format, os.tmpname builds its name from the temp directory and crypto, and debug.debug reads stdin with fs.readSync (end of input acts as cont). The browser map of package.json now stubs crypto instead of tmp and readline-sync.
  • string.format follows C more closely: exact ties round to even (%.0f of 2.5 is 2), exponents have two digits at least (1e+00), -0 keeps its sign in %g, and %0e of zero pads correctly. Checked against printf on about 19,500 random formats, and every legal precision (up to 99) runs without a host exception, %.99g and %.d included; test/cformat.test.js has 62 cases.
  • Dev dependencies: eslint 10, espree 11, jest 30.5, playwright-core 1.64.1, and globals declared (eslint.config.mjs imports it). yarn audit still lists sprintf-js 1.0.3 through jest (development only, no fix published).

0.9.0

Choose a tag to compare

@datamoc datamoc released this 13 Oct 12:46

Version 0.9.0

Performance

  • String buffers and copies: luaL_addchar no longer builds a view per character, and luastring_from copies typed arrays with new Uint8Array (3-4x faster than Uint8Array.from on short strings).
  • Real-world check, World Conquest II (the campaign's own Lua from Wesnoth 1.19, run by the mwg-wesnoth port, 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 five mwg-wesnoth Lua 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, because allocation and totalAllocation only 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 a LimitError (limit: "coroutines") that pcall cannot 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.tables and limits.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 uncatchable LimitError (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.
  • LimitError always 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 (the memory limit keeps not enough memory, as the script sees it). LimitError#limit is one of instructions, 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)

  • sandbox makes the host's own chunks text-only: run, runMulti and coroutineFromSource now load in text mode when sandbox: true, like the script's load already did, so a binary chunk never reaches the undumper from either side.
  • pushJson / readJson: a strict JSON exchange for hosts that run untrusted scripts. readJson refuses functions, threads, userdata, non-UTF-8 strings and exotic keys, creates no Lua reference (read anchors 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 }. pushJson pushes numbers as Lua floats and refuses anything that is not JSON, where push converts 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

Choose a tag to compare

@datamoc datamoc released this 13 Oct 02:57

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, because allocation and totalAllocation only 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 a LimitError (limit: "coroutines") that pcall cannot 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); the eval row stays in the matrix as a decision, not a runnable case.
  • New case coroutines compares 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

Choose a tag to compare

@datamoc datamoc released this 12 Oct 21:33

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. read now doubles its buffer as the bytes arrive, charging each real allocation to the allocator hook; a declared size the input cannot back is refused as truncated, 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 in test/lverify.test.js loads a chunk whose long-string constant declares 2 GB and checks both the refusal and the allocation peak through lua_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.global escape, prototype injection, constant mentioning eval, 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, and js.global exists only with fengari-interop; the constant filter was set aside. Case links such as #allocation select 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. LICENSE keeps 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)

Choose a tag to compare

@datamoc datamoc released this 12 Oct 17:46

Breaking: the last Fengari names are gone

  • PYROEISCONF replaces FENGARICONF as the environment variable that configures the VM ({"LUAI_MAXSTACK":100000}...). FENGARICONF is no longer read.
  • The Lua library fengari is now pyroeis (pyroeis.VERSION, RELEASE, COPYRIGHT, AUTHORS...). libs: ['pyroeis'] selects it; sandbox: true still leaves it out.
  • FENGARI_* constants are PYROEIS_* and describe Pyroeis (Pyroeis 0.6.0). The copyright keeps the Fengari and Lua attributions. luaopen_fengari is luaopen_pyroeis; LUA_FENGARILIBNAME is LUA_PYROEISLIBNAME.
  • The browser bundle no longer exposes the Fengari alias; use Pyroeis.

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

Choose a tag to compare

@datamoc datamoc released this 12 Oct 11:56

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 with malformed (...). JavaScript errors raised while reading a chunk become a load error instead of a silent status -1. A binary chunk remains trusted input; sandbox: true still refuses them all.
  • lua2js warnings can be unsafe when 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 cross pcall) 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

Choose a tag to compare

@datamoc datamoc released this 11 Oct 08:32

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 / pcall return normally and vm.run() returned its value. Every host entry point (run, call, callRef, Coroutine#resume, protect) now fails with a LimitError whenever a limit was hit during the call, even if Lua caught it.
  • Coroutine#close() ran its __close handlers without a budget; it now gets a fresh one.
  • Global path lookups (call, callMulti, compile, coroutine) ran before the budget, so an __index metamethod on _G could loop forever. They now run in a protected call under the limits.
  • sandbox: true no longer leaves the __random helper 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

Choose a tag to compare

@datamoc datamoc released this 11 Oct 08:00

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 of typeof process in 13 places; os.platform() became process.platform.
  • package.json has a browser field mapping fs, os, path, tmp, child_process and readline-sync to empty modules, so webpack, esbuild, Vite and Browserify bundle the package without shims or resolve.fallback.
  • test/browser-compat.test.js fails 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

Choose a tag to compare

@datamoc datamoc released this 11 Oct 06:16

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

Choose a tag to compare

@datamoc datamoc released this 11 Oct 06:02

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" in package.json) for FengariVm, LuaRef, LuaError, LimitError, Coroutine, Userdata, LuaFloat, the options and the lua2js result. The C-API namespaces are typed any for now.
  • vm.throwLuaError(L, index = -1): throws the Lua error value at index as a LuaError (LimitError after a limit overrun).
  • vm.protect(fn): runs a host callback in a Lua protected call, so Lua errors raised through the C API become LuaError / LimitError and the stack is restored.

See CHANGES.md for details.