Skip to content

Pass Ruby values into JS with VM#define_const / #define_let / #define_var - #67

Draft
hmsk wants to merge 8 commits into
mainfrom
define-variables
Draft

Pass Ruby values into JS with VM#define_const / #define_let / #define_var#67
hmsk wants to merge 8 commits into
mainfrom
define-variables

Conversation

@hmsk

@hmsk hmsk commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Pass Ruby values into JS without interpolating them into source.

vm = Quickjs::VM.new
vm.define_const(:user, { name: 'Itadori', tags: ['strong', 'kind'] })

vm.eval_code("user.name + ': ' + user.tags.join(', ')") #=> "Itadori: strong, kind"

Why three methods

"Globals" is ambiguous in this VM: let / const declarations persist across eval_code calls but never appear on globalThis. Picking one destination silently diverges from user code:

vm.eval_code('globalThis.a = 1;')
vm.eval_code('let a = 2; a')   #=> 2
vm.eval_code('globalThis.a')   #=> 1

So the caller picks the binding form instead, and JS's own rules apply unchanged. Nothing new to learn, and nothing invented.

JS can reassign it Redeclaring it in JS On globalThis
define_const no, raises Quickjs::TypeError Quickjs::SyntaxError no
define_let yes Quickjs::SyntaxError no
define_var yes allowed yes

define_var is the one for JS you don't control, a bundle reading globalThis.APP_CONFIG. Because these are real declarations rather than property assignments, a colliding declaration in user code is a loud error instead of a silent shadow.

Notes

  • Each declaration runs as its own eval, so caller source is never rewritten and backtrace line numbers keep pointing at the caller's code.
  • Values are serialized to JS source here rather than through the C converter, which falls back to #inspect for unrecognized objects and would silently turn a Time into a string. Unsupported values raise TypeError. Circular structures raise ArgumentError.
  • Names are validated against /\A[A-Za-z_$][A-Za-z0-9_$]*\z/, which is narrower than JS (it rejects Unicode identifiers like ). Without validation, a name splices arbitrary JS into the source we evaluate.
  • Redefining an existing let / var assigns to it. const and binding-form switches raise ArgumentError, tracked by a per-VM name -> kind registry that is only written after the eval succeeds.
  • The value is a snapshot, not a live reference. define_function remains the way to have JS read the current Ruby value on every access.
  • Pure Ruby, no C changes.

Open questions

  • define_var on a name user code already declared as var silently overwrites, since var redeclaration is legal JS. Only const / let collisions are loud, so define_var is the least protected of the three.
  • Bignum values are emitted as an integer literal and lose precision when JS parses them as a Number. Consistent with the existing C converter's Number(str) path, but silent.
  • One-shot Quickjs.eval_code is not covered, since there is no VM handle to define on.

Re-review against current main

Rebased and re-read after #66, #72, #73, #74 and #75 landed. No defects found; two gaps closed.

Module interaction is now covered. Injecting configuration and then importing a module that reads it is the obvious pairing with compile_module, and it is not obvious from either feature alone that a declaration reaches module scope, since a module has its own scope and only var lands on globalThis. Verified and pinned down with tests: a const is readable from an imported module, a var through globalThis, a const defined after the import is readable at call time (the binding resolves when the function runs, not when the module body evaluates), and a Runnable run on the VM sees it too.

The identifier docs overstated what is accepted. The README said the name "must be a valid JavaScript identifier", but is a valid JS identifier and this rejects it. The restriction is deliberate, so the docs now give the pattern and the reason.

Also checked, no action needed:

  • const / let persist across evals even though eval_code defaults to async: true. Load-bearing and non-obvious, already covered by the first test.
  • String values survive the JSON-to-JS-literal gap, including U+2028 / U+2029, which are legal in JSON strings and were illegal in JS string literals before ES2019.
  • Bignum precision loss matches the C converter exactly: 2**70 yields 1.1805916207174113e+21 through both paths, so this path is not worse than the rest of the gem.
  • define_var(:NaN, 1) silently no-ops, since NaN is a non-writable global and this is sloppy mode. That is JS behaviour, unchanged by us.

Validation

bundle exec rake — 597 runs, 946 assertions, 0 failures, 1 pre-existing skip. 36 new tests.

@hmsk
hmsk force-pushed the define-variables branch 2 times, most recently from 666af05 to e83128e Compare August 11, 2026 07:30
@hmsk
hmsk force-pushed the define-variables branch 2 times, most recently from f651bd6 to 89e5804 Compare August 18, 2026 06:32
@hmsk
hmsk force-pushed the define-variables branch from 89e5804 to 80a49d5 Compare August 27, 2026 08:00
hmsk and others added 3 commits August 27, 2026 02:02
Pass Ruby values into JS without interpolating them into source. The
caller picks the binding form, so JS's own rules apply unchanged: a
`const` is read-only and a colliding declaration in user code is a loud
SyntaxError, and `var` is the form that lands on `globalThis`.

Each declaration runs as its own eval so the caller's source is never
rewritten and backtrace line numbers keep pointing at their code.

Values are serialized to JS source here rather than through the C
converter, which falls back to `#inspect` for unrecognized objects and
would silently turn a Time into a string. Names are validated as JS
identifiers, without which a name would splice arbitrary JS into the
source we evaluate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-reading this against a main that has moved: #66 and #73 landed after
this branch was written, and injecting configuration and then importing a
module that reads it is the obvious pairing of the two. That a
declaration reaches module scope isn't obvious from either feature alone,
since a module has its own scope and only `var` lands on `globalThis`, so
it is worth pinning down. Same for a `Runnable` run on the VM.

The README said the name "must be a valid JavaScript identifier", which
overstates what is accepted: `値` is a valid JS identifier and this
rejects it. The restriction is deliberate, since the name is concatenated
into evaluated source, so the docs now give the pattern and the reason
instead of implying JS's own rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hmsk
hmsk force-pushed the define-variables branch from 80a49d5 to fe3e806 Compare August 27, 2026 09:02
var is the only form that lands on globalThis, which is what makes it the
right choice for a bundle expecting a global and also the only one an
existing property can intercept. Two shapes, both of which reported
success:

An accessor takes the value in its setter and hands JS back whatever its
getter likes:

  vm.eval_code("Object.defineProperty(globalThis, 'APP_CONFIG', {
    configurable: true,
    set(v) { globalThis.stolen = JSON.stringify(v) },
    get() { return { evil: true } } });")
  vm.define_var(:APP_CONFIG, { apiKey: 's3cret' })   #=> :APP_CONFIG
  vm.eval_code('globalThis.stolen')                  #=> "{\"apiKey\":\"s3cret\"}"
  vm.eval_code('APP_CONFIG.evil')                    #=> true

A non-writable property discards the assignment silently, because a
declaration eval is sloppy mode.

Both now raise ArgumentError before the value is written, so the accessor
never sees it. const and let are untouched: neither goes near globalThis,
and an accessor sitting on the name cannot observe them or change what the
name resolves to.

The check reads the VM through JS and the README says plainly that it is
not a defence. Code that has run in a VM owns that environment and can
replace Object.getOwnPropertyDescriptor as easily as it can install the
accessor. What it buys is that an unusable global fails loudly instead of
silently, and the README now says to define before running code you do not
control.

Found by review of #67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hmsk

hmsk commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Recording what the cold review turned up beyond the define_var problem already fixed in 89d573c, so it is attached to the code it describes rather than to issues about a file that is not on main yet. I verified each of these on this branch.

Nothing here blocks the merge as far as I can tell. Taking them in the PR or filing them once it lands both work; the split below is how I would divide them.

Worth deciding before this lands

Shared substructures expand rather than share. seen.delete on the way out is what makes the same object twice as siblings legal, and it also means a DAG is written out in full:

structure _js_literal output
x = {n: 1}; 10.times { x = [x, x] } 10,237 bytes
14 levels 163,837 bytes
18 levels 2,621,437 bytes
25 levels ~335 MB

Doubling per level. Reachable from a small Ruby structure with aliases, which YAML or Marshal produce without anyone intending it. A depth or output-size cap would bound it. Worth noting that #101 landed the opposite decision on the way out of the VM, for the same reason: a memo there was chosen partly so a guest could not turn a small graph into an enormous one.

A __proto__ key sets the prototype rather than becoming a property. Filed as #108, because the C converter does the same and this is gem-wide rather than yours. Mentioning it here only because this path is the deliberate input API, so it is a reasonable place to fix it first: ["__proto__"] as the emitted key rather than "__proto__".

Documentation, where the code and the README disagree

All verified on this branch:

  • vm.define_const(:x, "\xff\xfe".b) raises JSON::GeneratorError, and vm.define_const("abc".encode("UTF-16LE"), 1) raises Encoding::CompatibilityError. The README says anything unsupported raises TypeError. The first also diverges from "converted the same way as elsewhere in the gem": the C converter takes those bytes without complaint.
  • An ASCII-8BIT string whose bytes are valid UTF-8 is silently reinterpreted. "\xC3\xA9".b arrives as "é", length 1.
  • 10**400 becomes Infinity, not a loss of precision.
  • {"a" => 1, :a => 2} emits both keys and JS keeps the last, so the Hash silently loses an entry. Same for {1 => "x", "1" => "y"}.
  • A File or an Exception raises TypeError here, while the C converter handles both.
  • "A colliding declaration in your JS is a loud error instead of a silent shadow" holds at top-level script scope only. In a function body, and in module scope, the shadow is silent: a module declaring const injected shadows an injected const with no error.
  • RESERVED_WORDS reads as protection against clobbering, and it is not: define_var(:eval, 1) makes typeof eval "number", and define_var(:Object, 1) breaks Object.keys. Same family as the define_var problem, and arguably now covered by the ordering advice, but the constant's name promises more than it delivers.

Robustness

_js_literal blows the Ruby stack at around depth 2619, with SystemStackError rather than anything worse. Circular structures already get a clean ArgumentError; merely deep ones do not. The C converter has no depth guard either, so this is a nit rather than a defect.

What the review could not break

No escape from a literal into executable JS, through string values, symbols, hash keys, floats, integers, or the variable name. NAME_PATTERN is anchored \A…\z so nothing gets past it with a newline, to_json escapes the full set that can terminate a JS string, and U+2028/U+2029 pass through raw where QuickJS accepts them. The disposed, owning-thread and OOM-poisoned guards are all inherited through eval_code rather than bypassed, and a failed declaration never writes the registry.

hmsk and others added 3 commits August 28, 2026 15:35
A Ruby structure that reaches the same object twice is written out twice,
so a graph whose branches repeat expands as it nests: each level that
references the level below twice doubles the output. Twenty-five of those
is a third of a gigabyte from a handful of objects, thirty is ten. YAML
aliases and Marshal round-trips produce that shape without anyone meaning
to, and nothing stopped it.

The bound is the VM's own memory_limit rather than a number invented for
the occasion. A source larger than the whole JS heap budget cannot be
evaluated by that VM under any circumstances, so this refuses work that
provably cannot succeed, and the message names the option to change.

It is a ceiling and not a prediction, which the README now says: a 256KB
source already exhausts a 4MB VM once parsed, because object-heavy
literals cost many times their source size. Sources between those two
points remain the caller's problem.

Checked per container rather than once at the end, since the expansion is
bottom-up and an inner container crosses the line long before the outer
one exists. Peak Ruby memory measured at three to four times the limit
across limits from 4MB to 128MB, proportional to the limit rather than to
the structure.

Found by review of #67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{"__proto__": v}` in an object literal is the prototype-setting form, and
quoting the key does not opt out of it. A Hash key of that name therefore
vanished: the entry became the object's prototype, `Object.keys` never
listed it, and the value came back from JS as if it had never been sent.

  vm.define_const(:o, { '__proto__' => { 'p' => 1 } })
  vm.eval_code('Object.getOwnPropertyNames(o).length')  # was 0, now 1
  vm.eval_code('JSON.stringify(o)')                     # was {}, now the object

The round trip is the clearest statement of it. Before:

  sent      {"name" => "bob", "__proto__" => {"isAdmin" => true}}
  received  {"name" => "bob"}

Data going missing is half of it. The other half is that Ruby and JS then
disagree about what the value contains, which is the shape a host-side
check gets walked past: a request body with a nested "__proto__" reads in
Ruby as an ordinary key with a Hash under it, `body['isAdmin']` is nil,
and it arrives in JS with `req.isAdmin` true and `Object.keys` showing
nothing. Ruby's own JSON.parse produces that key without comment, so a
parsed body reaches this directly.

A computed key is not the special form. Only this one name needs it, since
nothing else in an object literal means anything but itself.

This is the generated source only. A guest writing `{__proto__: x}` still
sets a prototype, which is what the language says it should do, and JS to
Ruby was already correct: the walk reads own properties, so a `__proto__`
property arrives as that key and a prototype is not collected.

The same key through the C converter is unchanged and stays open as #108.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review of this pull request found several places where the
documentation described something other than the behaviour. Each claim
below was run against the branch rather than reasoned about.

The scope claim was too broad. A colliding declaration is a loud error at
the top level only; a function body or a module shadows silently, as
JavaScript always has. The paragraph said so without the qualifier, which
is the version that matters to someone importing a module.

"Converted the same way as elsewhere in the gem" was not true either. This
path takes a narrower set than the converter used for define_function
return values, which also handles File and Exception. That is deliberate,
since defining is an input path, so the text now says which way it is
narrow and why rather than claiming they match.

Four edges are named that were not documented at all: an ASCII-8BIT string
whose bytes are valid UTF-8 is reinterpreted as those characters; integers
past JavaScript's range become Infinity rather than losing precision; Hash
keys collide after to_s and the last one wins; and the reserved-word check
does not stop you replacing a built-in, which `define_var(:eval, 1)`
demonstrates.

The exception classes are documented as they are rather than smoothed
over. JSON::GeneratorError for bytes that are not valid UTF-8 and
Encoding::CompatibilityError for a name that cannot be compared against
ASCII both leak the layer that noticed. Reporting them as TypeError would
read better and is worth doing on its own, not as part of a documentation
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hmsk

hmsk commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Closing out the list above, since most of it has been dealt with since and the comment reads as if none of it had.

Fixed here

define_var onto a global that captures or discards the value 89d573c
Shared sub-structures expanding without a bound b624590
A __proto__ key becoming the prototype 62fd78b
The documentation saying things the code does not do the README commit

The three code fixes are separate commits and each reverts on its own. The __proto__ one could not be a separate pull request: lib/quickjs/variables.rb does not exist on main, so there would be nothing for it to change. The same key through the C converter is untouched and stays open as #108.

Left deliberately, and the one thing still worth doing

The exception classes. Two unsupported inputs report the layer that noticed them rather than the contract the API advertises:

vm.define_const(:x, "\xff\xfe".b)             #=> JSON::GeneratorError
vm.define_const("abc".encode("UTF-16LE"), 1)  #=> Encoding::CompatibilityError

Both are unsupported values, and the README says unsupported values raise TypeError. It now documents them as they are rather than as they should be, which is honest but not the end state.

Kept out of the documentation pass on purpose: changing what a method raises is a behaviour change, and burying one in a commit that claims to be correcting prose is how a behaviour change gets shipped without being reviewed as one. It also wants a decision rather than a patch, since JSON::GeneratorError is arguably useful information that a bare TypeError would throw away, so the answer may be a TypeError that carries the original as its cause rather than a plain replacement.

Worth an issue once this lands, for the same reason the __proto__ fix could not be its own pull request: there is nothing on main to file it against yet.

A second review of this branch found one escape and four edges. All of
them were reproduced before being fixed.

**A String subclass could write the source.** The value path called
`value.to_json`, which is dispatched on the caller's object:

  class Evil < String
    def to_json(*) = %q{1; globalThis.PWNED = 'yes'; var zz = 1}
  end
  vm.define_let(:v, Evil.new('hi'))
  vm.eval_code('globalThis.PWNED')   #=> "yes"

The key path was already written as to_s.to_json and was never reachable
this way. The value path now matches it. Host-side rather than guest-side
under the threat model here, and a parsed request body has no String
subclasses in it, but the comment above the name check claims escaping is
what stops arbitrary JS reaching the source, and that should hold for
values without depending on what a caller's object does.

**The budget bounded the result, not the work.** _within_budget ran after
map and join had materialised the whole container, so width was unbounded:
three hundred references to one twenty-thousand-element array reached
103MB of Ruby against a 4MB limit that never fired in time. Counting as
each element is produced stops that value at 6MB. The doubling shape drops
from 42MB to 30MB at a 4MB limit and from 866MB to 548MB at the default.

That makes the comment I put on this in b624590 wrong twice over, and it
now says what was measured instead: a multiple of the limit rather than
the limit, four to eight times for nesting and about one and a half for
width, and no longer proportional to the structure.

**Depth is capped at 1000.** Deeper values raised SystemStackError, which
is not a StandardError, so a caller's `rescue => e` did not catch it and
the thread went down.

**A memory_limit at or above 2**63** reads back negative through
malloc_limit, which is an int64_t, and every define on an otherwise
healthy VM compared against it and refused.

**The define_var guard could be taken away by its own caller.** It reads
globalThis and Object through JS, so define_var(:globalThis, 1) made every
later probe answer for a Number and report a clean global, and
define_var(:Object, 1) made it throw with a message naming nothing. Both
now refuse rather than reporting a success that did not happen.

Also documented, not changed: a name defined here shadows a
define_function of the same name silently, in either order. And the
reserved-word comment claimed QuickJS refuses those words anyway, which is
true of await and false of the strict-mode-only ones, since a declaration
eval is sloppy mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant