Skip to content

Rak v0.8.4

Choose a tag to compare

@github-actions github-actions released this 03 Oct 16:52
· 36 commits to main since this release

0.8.4 — 2026-10-03

Security and performance release. This one should be read before upgrading, not
after.

Security

FFI pointer provenance — an arbitrary write anywhere in the process is closed.

ffi_write and ffi_read did this:

unsafe { *((ptr as usize + off) as *mut u8) = byte }

with no check that ptr was ever allocated, and none that off was inside it.
Because ffi_ptr(n) builds a pointer from any integer,
ffi_write(ffi_ptr(ADDRESS), 0, 0x41) could write a byte anywhere the process
could reach. ffi_read was the matching read. This is the most severe item in the
security review, and unlike most of that list it was completely real.

A pointer is now provenanced if Rak allocated it (ffi_alloc,
ffi_string_to_cstr) or was told it owns a region (ffi_trust). Provenanced
pointers are range-checked on every access against the size recorded at allocation.
A pointer that is neither is refused by name, and the error says what to do about
it.

ffi_trust is the deliberate escape hatch. A language with FFI that refused every
foreign address would be useless, and resolving a symbol's address and then calling
it is legitimate work. What is not acceptable is an address that is silently
accepted, because that is indistinguishable from a safety check that always passes.
ffi_trust turns unchecked pointer arithmetic into an assertion written down in the
source — the same bargain unsafe { reason } makes everywhere else. It also
narrows: trusting 4 bytes of a 16-byte allocation makes an access at offset 4 fail
again, which is tested.

ffi_cstr_to_string was a denial of service by another route — it scanned for a NUL
byte with no bound, so a pointer to a NUL-free buffer walked off the end into
unmapped memory, reachable from a pointer that came out of a network response. It
is now bounded at 1 MiB.

ffi_read_i32 checks all four bytes, not merely that off is inside: off being
valid while the read runs three bytes past the end is still a read past the end.

The bounds arithmetic is done in u128, not u64, on purpose. ptr + off in u64
wraps, and a bounds check a large offset can defeat is not a check. There is a test
that specifically tries to defeat it.

Both backends share rak_stdlib::ffi::Allocations so the logic lives in one tested
place. ffi_trust is covered by the existing ffi_ capability prefix, so it needs
no new sandbox entry.

Existing ffi_alloc/ffi_free and legitimate access are unchanged and tested.

Performance

Constant folding. compile_expr had none. Every 2 * 3 became two LoadConsts,
an AddI and a push, on every evaluation — inside the innermost loop of every
numeric program. fold_int_binary now folds the literal-on-literal case for the
arithmetic and bitwise operators, and is deliberately narrow: both operands must be
literals; division or remainder by zero, out-of-range shift counts, and overflow all
fold to nothing so the runtime behaviour is unchanged.

rakc bench now measures something. The old version timed
rakc::eval(&source) against vm.run() and nothing else. That is not a backend
comparison: the interpreter figure included lexing, parsing and setup, the VM figure
was bytecode execution on an already-compiled chunk, and compile time was attributed
to nobody. It also used as_millis(), so anything under a millisecond printed
0 ms.

It now reports five phases — lex, parse, compile, interp, vm — each over --repeat
samples (default 5) after an unmeasured warm-up, as a median. The two execution
numbers are finally like for like, and the output labels them "execution only" so
they cannot be misread. On examples/bench.rak in a debug build it reports 6.61x,
which is a measured number where the README previously had a hand-written estimate.

A benchmark corpus. examples/bench/ has six workloads covering recursion,
arithmetic, arrays, map fields, strings and bytes.

Fixes

Three backend message divergences, all the same defect — the VM and the interpreter
described the same failure differently, so an error message told you which backend
you were on:

expression interpreter VM (before)
5 % 0 Division by zero rem by zero
5 / 0 Division by zero div by zero
n * x Undefined variable: x Undefined: x

The first two were Rust's internal panic wording copied into hand-written Err
strings. The interpreter was itself inconsistent on the third (Undefined variable:
in two places, Undefined: in a third); both backends are now normalised to the
clearer form.

Upgrade notes

If you use FFI, you may need to add ffi_trust calls. Any ffi_write/ffi_read
through a pointer that did not come from ffi_alloc or ffi_string_to_cstr will now
fail at the point of use rather than corrupting memory silently — which is the
intended behaviour, but it is a behavioural change.

Release notes are generated from .github/release_body.md at tag time.