Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
21 changes: 20 additions & 1 deletion src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
}
74 changes: 58 additions & 16 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<unknown builtin>";
}
#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)
: "<unknown builtin>",
i, VM_BORROW_SCAN_CAP, g_vm.current_line);
abort();
}
}
#endif
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/vm.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
23 changes: 23 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$?
Expand Down
75 changes: 75 additions & 0 deletions tests/test_borrow_guard.sh
Original file line number Diff line number Diff line change
@@ -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
Loading