From 5c7fd737ee33a477ab41f6f31d4003a58216cdd4 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Fri, 10 Jul 2026 23:19:32 -0500 Subject: [PATCH] =?UTF-8?q?feat(vm):=20sanitizer-build=20borrow-scan=20gua?= =?UTF-8?q?rd=20=E2=80=94=20abort=20on=20a=20missed=20borrow=20past=20the?= =?UTF-8?q?=20cap=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #546 cap on vm_borrow_scan rests on 'no builtin returns a borrowed direct child past index 7', enforced only by a comment. A future violation is a missed compensating incref: the VM pushes a result it doesn't own, and the over-release fires later and lifetime-dependently — a heisenbug UAF, not a test failure. In ASan builds (compiler-set macro gate, EIGS_BORROW_GUARD in vm.h — zero release cost) the scan now continues past VM_BORROW_SCAN_CAP and aborts on a match the capped scan missed, naming the builtin via an env-chain reverse lookup that runs only on the abort path. The cap moved to vm.h so the self-test can reference it. Planted-fault validation (a checker nobody watched fail is not a checker): __borrow_guard_selftest deliberately returns items[count-1] of a >cap arg vector. Registered only in sanitizer builds AND under EIGS_BORROW_GUARD_SELFTEST, so fuzz harnesses (also ASan builds) can never reach a deliberate abort. Suite [119], 4 checks: within-cap clean, past-cap aborts rc=134, message names builtin+cap, opt-in absent by default. SKIPs on release; documented in BUILTINS.md (stdlib index gate now tracks 215). Closes #548 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++++++ docs/BUILTINS.md | 6 +++ src/builtins.c | 21 ++++++++++- src/vm.c | 74 +++++++++++++++++++++++++++++-------- src/vm.h | 21 +++++++++++ tests/run_all_tests.sh | 23 ++++++++++++ tests/test_borrow_guard.sh | 75 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 tests/test_borrow_guard.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 288e184..93bdf88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added +- **Borrow-scan guard in sanitizer builds (#548).** The #546 cap on + `vm_borrow_scan` rests on the invariant *"no builtin returns a + borrowed direct child past index 7"*, which was enforced only by a + comment. In ASan builds the scan now continues past + `VM_BORROW_SCAN_CAP` and `abort()`s naming the offending builtin + (env-chain reverse lookup) on a match the capped scan missed — + converting a future missed compensating incref from a + lifetime-dependent use-after-free heisenbug into an immediate red + test run. Zero release-build cost (compiler-set ASan macro gate). + Validated by a planted fault: `__borrow_guard_selftest` (registered + only in sanitizer builds under `EIGS_BORROW_GUARD_SELFTEST`, so + fuzzers can't reach a deliberate abort) violates the invariant on + purpose; suite [119] proves the abort fires, names the builtin, and + that within-cap borrows stay clean. SKIPs on release. + ### Fixed - **Dot access accepts keyword-named fields (#542).** `d.loop`, `d.in`, `d.when` — any word keyword now parses as a dot key (read, write, diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index d02b1ec..376f5e5 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -613,3 +613,9 @@ receiver. | `audio_play_loop` | `audio_play_loop of [samples, loops]` | Play `samples` `loops` times on one mixer channel; `loops == -1` loops forever (the mixer rewinds — no memory multiplication). Returns the channel id, or `0` on bad args / closed device. | | `audio_volume` | `audio_volume of [channel, vol]` | Live per-channel volume, `0.0`–`4.0`. Returns `1`, or `0` on a bad/inactive channel. | | `audio_stop` | `audio_stop of channel` | Stop one mixer channel. Returns `1`, or `0` on a bad/inactive channel. | + +## Internal (sanitizer builds only) + +| Name | Signature | Description | +|------|-----------|-------------| +| `__borrow_guard_selftest` | `__borrow_guard_selftest of [args...]` | **Not a user builtin.** A planted fault validating the #548 borrow-scan guard: registered only in ASan builds when `EIGS_BORROW_GUARD_SELFTEST` is set, it deliberately returns a borrowed direct child past `VM_BORROW_SCAN_CAP` so the suite can prove the guard aborts loudly (naming the builtin) instead of letting a missed compensating incref become a silent use-after-free. Absent from release builds and from sanitizer builds without the opt-in env var (fuzzers must never reach a deliberate abort). | diff --git a/src/builtins.c b/src/builtins.c index 859a26d..d863ed5 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -5873,6 +5873,21 @@ static Value* builtin_dot(Value *arg) { return make_num(s); } +#if EIGS_BORROW_GUARD +/* #548 planted fault: deliberately violates the borrow-scan invariant by + * returning a borrowed direct child PAST VM_BORROW_SCAN_CAP. Exists only + * to prove the guard converts a missed borrow into a loud abort — a + * checker nobody has watched fail is not a checker. Registered only in + * sanitizer builds AND under EIGS_BORROW_GUARD_SELFTEST, so fuzzers + * (whose harnesses are ASan builds) can never reach a deliberate abort. */ +static Value* builtin_borrow_guard_selftest(Value *arg) { + if (arg && arg->type == VAL_LIST && + arg->data.list.count > VM_BORROW_SCAN_CAP) + return arg->data.list.items[arg->data.list.count - 1]; + return NULL; /* VM substitutes null: not enough args to violate */ +} +#endif + void register_builtins(Env *env) { /* ---- Core language builtins (always available) ---- */ env_set_local_owned(env, "print", make_builtin(builtin_print)); @@ -6123,5 +6138,9 @@ void register_builtins(Env *env) { register_model_builtins(env); #endif - +#if EIGS_BORROW_GUARD + /* #548 guard self-test hook — see builtin_borrow_guard_selftest. */ + if (getenv("EIGS_BORROW_GUARD_SELFTEST")) + env_set_local_owned(env, "__borrow_guard_selftest", make_builtin(builtin_borrow_guard_selftest)); +#endif } diff --git a/src/vm.c b/src/vm.c index 7262f48..5b34af4 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1660,26 +1660,68 @@ void jit_helper_index_get(void) { * no spurious ref is added; nested borrows (get_at: items[0][idx]) * must still incref locally — only direct children are scanned. * - * #546: the scan is CAPPED. A borrowing builtin can only return a - * child it actually read, and every builtin reads its argument vector - * at small fixed indices (max arity in the registry is 7), so items - * past the cap can never be the borrowed result. Without the cap, a - * single-list-argument call (`len of xs`) scanned the user's entire - * list after every call — O(len) per call, turning the idiomatic - * `loop while i < (len of xs):` quadratic. Raise the cap if a builtin - * with a larger argument vector that RETURNS one of its args is ever - * added (the assert-ish comment in builtins.c's registry is the - * reminder). + * #546: the scan is CAPPED (VM_BORROW_SCAN_CAP, vm.h). A borrowing + * builtin can only return a child it actually read, and every builtin + * reads its argument vector at small fixed indices (max arity in the + * registry is 7), so items past the cap can never be the borrowed + * result. Without the cap, a single-list-argument call (`len of xs`) + * scanned the user's entire list after every call — O(len) per call, + * turning the idiomatic `loop while i < (len of xs):` quadratic. + * Raise the cap if a builtin with a larger argument vector that + * RETURNS one of its args is ever added. + * + * #548: in sanitizer builds the invariant is machine-checked — see + * EIGS_BORROW_GUARD in vm.h and the guard block below. A violation + * aborts naming the builtin instead of surfacing later as a + * lifetime-dependent use-after-free. */ -#define VM_BORROW_SCAN_CAP 8 -static inline void vm_borrow_scan(Value *arg, Value *result) { +#if EIGS_BORROW_GUARD +/* Name a builtin by its fn pointer for the abort diagnostic: walk the + * env chain from the call frame to the root scanning bindings. Runs + * only on the abort path, so the O(bindings) walk costs nothing. */ +static const char* vm_builtin_name_for(Env *env, BuiltinFn fn) { + for (; env; env = env->parent) { + for (int i = 0; i < env->count; i++) { + EigsSlot s = env->values[i]; + if (!slot_is_heap(s)) continue; + Value *v = slot_as_ptr(s); + if (v && v->type == VAL_BUILTIN && v->data.builtin == fn) + return env->names[i]; + } + } + return ""; +} +#endif + +static inline void vm_borrow_scan(Value *arg, Value *result, + Value *fn_val, Env *env) { + (void)fn_val; (void)env; if (arg && arg->type == VAL_LIST) { int n = arg->data.list.count; if (n > VM_BORROW_SCAN_CAP) n = VM_BORROW_SCAN_CAP; Value **items = arg->data.list.items; for (int i = 0; i < n; i++) { - if (items[i] == result) { val_incref(result); break; } + if (items[i] == result) { val_incref(result); return; } + } +#if EIGS_BORROW_GUARD + /* #548: the capped scan found no borrow — prove there is none + * past the cap. A match here means a missed compensating + * incref: the VM would push a result it doesn't own, and the + * over-release becomes a heisenbug UAF. Fail loudly instead. */ + for (int i = n; i < arg->data.list.count; i++) { + if (items[i] == result) { + fprintf(stderr, + "EigenScript FATAL: borrow-scan guard (#548): builtin " + "'%s' returned a borrowed direct child at arg index %d, " + "past VM_BORROW_SCAN_CAP=%d (line %d). Raise the cap in " + "src/vm.h or incref the child locally in the builtin.\n", + fn_val ? vm_builtin_name_for(env, fn_val->data.builtin) + : "", + i, VM_BORROW_SCAN_CAP, g_vm.current_line); + abort(); + } } +#endif } } @@ -1893,7 +1935,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { } else if (!consumes_arg && result != arg) { /* Direct-borrow heuristic (capped, #546) — see vm_borrow_scan; * !consumes_arg guard: free_val may have already freed arg. */ - vm_borrow_scan(arg, result); + vm_borrow_scan(arg, result, fn_val, frame->env); } if (!consumes_arg && result != arg) val_decref(arg); vm_push(result); @@ -3186,7 +3228,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * !consumes_arg: a consuming builtin like free_val may * have already freed arg, so reading arg here would be * a use-after-free. */ - vm_borrow_scan(arg, result); + vm_borrow_scan(arg, result, fn_val, frame->env); } if (!consumes_arg && result != arg) val_decref(arg); /* If result == arg, the arg's refcount transfers to the result */ @@ -5049,7 +5091,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * vm_borrow_scan. Must run before val_decref(arg) so * the items array is still valid, and skipped when the * builtin already consumed arg (free_val). */ - vm_borrow_scan(arg, result); + vm_borrow_scan(arg, result, fn, frame->env); } if (!consumes_arg && result != arg) val_decref(arg); slot_decref(table_s); diff --git a/src/vm.h b/src/vm.h index 8fdd441..e2d98cf 100644 --- a/src/vm.h +++ b/src/vm.h @@ -22,6 +22,27 @@ typedef struct ASTNode ASTNode; * includes vm.h before eigenscript.h, slot_incref / slot_decref will * be incomplete-typed. All current TUs include eigenscript.h first. */ +/* Direct-borrow scan cap (#546): a borrowing builtin can only return an + * argument it actually read, and every registered builtin reads its arg + * vector at small fixed indices (max arity 7). Shared with builtins.c so + * the #548 guard self-test can construct a past-the-cap violation. */ +#define VM_BORROW_SCAN_CAP 8 + +/* #548: in sanitizer builds the borrow scan keeps scanning past the cap + * and aborts on a match the capped scan missed — a builtin returning a + * borrowed direct child past index 7 would otherwise become a silent + * lifetime-dependent use-after-free. Zero cost in release builds. */ +#if defined(__SANITIZE_ADDRESS__) +# define EIGS_BORROW_GUARD 1 +#elif defined(__has_feature) +# if __has_feature(address_sanitizer) +# define EIGS_BORROW_GUARD 1 +# endif +#endif +#ifndef EIGS_BORROW_GUARD +# define EIGS_BORROW_GUARD 0 +#endif + /* ---- Opcodes ---- */ typedef enum { /* Constants */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 3379220..ddea160 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1064,6 +1064,29 @@ echo "[118] Keyword Dot Keys (#542, 49 checks)" check_eigs_suite "all 39 keywords + chains/json/paren/literal as dot keys" \ test_dict_keyword_keys.eigs "All tests passed" 49 +# [119] Borrow-scan guard (#548): sanitizer builds full-scan past +# VM_BORROW_SCAN_CAP and abort naming the builtin on a missed borrow — +# validated by a planted fault (opt-in selftest builtin). SKIPs on +# release builds, where the guard is compiled out by design. +echo "[119] Borrow-Scan Guard (#548, 4 checks; SKIP on release)" +BG_OUTPUT=$(bash "$TESTS_DIR/test_borrow_guard.sh" 2>&1) +if echo "$BG_OUTPUT" | grep -q "SKIP:"; then + echo "$BG_OUTPUT" | grep "SKIP:" +else + BG_PASS=$(echo "$BG_OUTPUT" | grep -c "PASS:" || true) + BG_FAIL=$(echo "$BG_OUTPUT" | grep -c "FAIL:" || true) + TOTAL=$((TOTAL + BG_PASS + BG_FAIL)) + PASS=$((PASS + BG_PASS)) + FAIL=$((FAIL + BG_FAIL)) + if [ "$BG_FAIL" -gt 0 ]; then + echo " FAIL: $BG_FAIL borrow-guard check(s) failed" + echo "$BG_OUTPUT" | grep "FAIL:" | head -5 + else + echo " PASS: all $BG_PASS borrow-guard checks" + fi +fi +echo "" + # [23] Named parameters echo "[23/27] Named Parameters (9 checks)" NP_OUTPUT=$(./eigenscript ../tests/test_named_params.eigs 2>&1); NP_OUTPUT_RC=$? diff --git a/tests/test_borrow_guard.sh b/tests/test_borrow_guard.sh new file mode 100644 index 0000000..0c52a11 --- /dev/null +++ b/tests/test_borrow_guard.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Borrow-scan guard tests (#548): in sanitizer builds vm_borrow_scan keeps +# scanning past VM_BORROW_SCAN_CAP and aborts, naming the builtin, when a +# builtin returns a borrowed direct child the capped scan missed (a silent +# lifetime-dependent UAF in release). Validated with a planted fault: the +# __borrow_guard_selftest builtin (ASan builds + EIGS_BORROW_GUARD_SELFTEST +# only) deliberately violates the invariant. +# +# Run directly or from run_all_tests.sh. Prints PASS:/FAIL: lines (or one +# SKIP: line on non-sanitizer builds, where the guard is compiled out). +# Exit code: 0 if all pass or skipped, 1 if any fail. + +set -u +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$TESTS_DIR/.." && pwd)" +EIGS="$ROOT/src/eigenscript" + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d -t eigs_borrow_guard.XXXXXX) +trap 'rm -rf "$TMPDIR"' EXIT + +ok() { echo " PASS: $1"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $1${2:+ ($2)}"; FAIL=$((FAIL+1)); } + +printf 'r is __borrow_guard_selftest of [1,2,3]\nprint of "WITHIN_CAP_OK"\n' > "$TMPDIR/within.eigs" +printf 'r is __borrow_guard_selftest of [1,2,3,4,5,6,7,8,9,10]\nprint of r\n' > "$TMPDIR/past.eigs" + +# Build-type probe: the selftest builtin exists only when the guard is +# compiled in (sanitizer builds). On release, skip — the guard is +# deliberately zero-cost there, so there is nothing to exercise. +probe=$(EIGS_BORROW_GUARD_SELFTEST=1 "$EIGS" "$TMPDIR/within.eigs" 2>&1) +if echo "$probe" | grep -q "undefined variable '__borrow_guard_selftest'"; then + echo " SKIP: non-sanitizer build — borrow guard compiled out (release is zero-cost by design)" + exit 0 +fi + +# 1. Within-cap call is compensated normally: no abort, clean exit. +if echo "$probe" | grep -q "WITHIN_CAP_OK"; then + ok "within-cap borrow runs clean (no abort)" +else + fail "within-cap borrow runs clean" "got: $(echo "$probe" | head -1)" +fi + +# 2. Past-cap violation aborts (SIGABRT), never a silent success. +out=$(EIGS_BORROW_GUARD_SELFTEST=1 "$EIGS" "$TMPDIR/past.eigs" 2>&1) +rc=$? +if [ "$rc" -ne 0 ]; then + ok "past-cap borrow aborts (rc=$rc)" +else + fail "past-cap borrow aborts" "exited 0 — guard did not fire" +fi + +# 3. The abort names the offending builtin and the cap. +if echo "$out" | grep -q "borrow-scan guard (#548)" \ + && echo "$out" | grep -q "__borrow_guard_selftest" \ + && echo "$out" | grep -q "VM_BORROW_SCAN_CAP"; then + ok "abort message names the builtin and the cap" +else + fail "abort message names the builtin and the cap" "got: $(echo "$out" | head -1)" +fi + +# 4. The planted fault is opt-in: without EIGS_BORROW_GUARD_SELFTEST the +# builtin is not registered (fuzzers must never reach a deliberate abort). +out=$("$EIGS" "$TMPDIR/past.eigs" 2>&1) +if echo "$out" | grep -q "undefined variable '__borrow_guard_selftest'"; then + ok "selftest builtin absent without the opt-in env var" +else + fail "selftest builtin absent without the opt-in env var" "got: $(echo "$out" | head -1)" +fi + +echo "" +echo "borrow-guard: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0