fix: free t->dirty; point the gate at the manifest gcc (real ASan)
Two things the now-working sanitizer found immediately.
1. t->dirty was never freed. vtnew callocs it, every termresize reallocs it, and vtfree frees eight other fields but not this one — so every emulator destroyed leaked its dirty array (rows bytes, unbounded across a session that opens and closes terminals). `detectleaks=1` has been set in the gate from the start and would have caught it on day one; it reported nothing because ASan was never linked.
2. The gate now uses the toolchain that actually has ASan. The monorepo manifest already carries gcc-toolchain, whose ASan is real and which keeps it under -fsanitize=address,undefined (the silent drop is zig-specific). Run it as:
guix shell -m ../sigil/manifest.scm -- spike/fuzz.sh There are TWO ways to end up silently blind, and neither is visible in the
output: using zig (no ASan runtime), or running the gcc-built binary OUTSIDE
the guix shell — the same binary then exits cleanly on a deliberate
over-read, looking exactly like a pass. The self-test catches both without
having to detect either, because it just asks the build to prove it fires. The self-test now matches on the AddressSanitizer DIAGNOSTIC rather than an
exit code: a caught over-read aborts (SIGABRT), so it can never exit 0, and
a bare non-zero exit could just mean the binary failed to launch. The
earlier exit-code contract was unsatisfiable and would have refused a
working toolchain.Full gate on this branch, real ASan verified firing: corpus replay + 3,000,000 mutation iterations, leak detection on, CLEAN. That is the first green this gate has produced that means anything.
native/vt.c | 7 +++++++
spike/fuzz.sh | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------
spike/sanitizer-selftest.c | 13 ++++++++++---
3 files changed, 67 insertions(+), 37 deletions(-)native/vt.cmodified
} free(t->sb); free(t->sb_w); /* t->dirty is allocated in vt_new and realloc'd by every term_resize, and * was never freed: EVERY emulator destroyed leaked its dirty array (rows * bytes — unbounded across a session that opens and closes terminals). * `detect_leaks=1` has been set in the fuzz gate from the start and would * have caught this on day one; it reported nothing because ASan was never * linked (t-d4c7). Found within seconds of the gate being able to see. */ free(t->dirty); for (int i = 0; i < t->nevents; i++) free(t->events[i].payload); free(t->events); free(t);spike/fuzz.shmodified
#!/usr/bin/env bash# M2 GATE: build + run the sigil-vt fuzz driver under the sanitizers.# The pure C core (native/vt.c under -DVT_FUZZ) is standalone — no libsigil.## RUN IT LIKE THIS (compiler AND runtime must both come from the manifest):## guix shell -m ../sigil/manifest.scm -- spike/fuzz.sh## The monorepo manifest already carries gcc-toolchain, whose ASan is REAL.# Two ways to end up with a silently blind gate — both caught by the self-test# below, neither obvious from the output:## 1. THE PINNED ZIG HAS NO ASan RUNTIME. `zig cc -fsanitize=address` alone# fails to link (undefined __asan_report_load4); with `address,undefined`# it links and SILENTLY DROPS ASan (zero __asan symbols in the binary) and# still prints FUZZ CLEAN. gcc keeps ASan with the same flag combo — the# silent drop is zig-specific. Do not use zig for this gate.# 2. RUNNING OUTSIDE THE GUIX SHELL. The same gcc-built binary does NOT fault# when run outside the shell — it exits cleanly, looking exactly like a# pass. Compile AND run inside.## This is not hypothetical: a real heap over-read (t-d4c7) shipped through# 5,000,000 "clean" iterations of this gate.set -euo pipefailcd "$(dirname "$0")/.."# Any toolchain with a REAL AddressSanitizer runtime. Override if zig is not on# PATH, e.g. the Sigil monorepo's pinned toolchain:# ZIG=../sigil/tools/zig/zig spike/fuzz.sh# NOTE: the pinned zig currently ships NO ASan runtime — see the self-test.ZIG="${ZIG:-zig}"CC="${CC:-gcc}"ITERS="${1:-3000000}"SAN_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all"mkdir -p build# ---------------------------------------------------------------------------# STEP 0 — PROVE THE GATE CAN FIRE, BEFORE TRUSTING ANYTHING IT SAYS.## t-d4c7: this gate reported "5,000,000 iterations, ASan/UBSan clean" while# ASan was NOT LINKED AT ALL, and a real heap over-read shipped through it.# `zig cc -fsanitize=address` alone fails to link (undefined __asan_report_*);# with `address,undefined` it links and SILENTLY DROPS ASan (the binary has# zero __asan symbols). The run still prints CLEAN — which is worse than having# no gate, because it reads as assurance.## So: compile a KNOWN heap over-read with the SAME flags and require it to be# caught. A gate that cannot demonstrate it fires is not a gate.# Compile a KNOWN heap over-read with the SAME compiler and flags, and require# it to be caught. A gate that cannot demonstrate it fires is not a gate. This# check covers BOTH failure modes above without having to detect either.# ---------------------------------------------------------------------------echo "== sanitizer self-test (can this build detect a heap over-read?) ==""$ZIG" cc -std=c99 -O1 -g $SAN_FLAGS spike/sanitizer-selftest.c -o build/san-selftest"$CC" -std=c99 -O1 -g $SAN_FLAGS spike/sanitizer-selftest.c -o build/san-selftestset +eASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ ./build/san-selftest >/dev/null 2>&1san_out="$(ASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ ./build/san-selftest 2>&1)"rc=$?set -eif [ "$rc" -eq 0 ]; then# Match the DIAGNOSTIC, not the exit code. A caught over-read ABORTS (SIGABRT,# rc 134) — it can never exit 0 — while a bare non-zero exit could just mean the# binary failed to launch. Only an actual ASan report proves the gate fired.if printf '%s' "$san_out" | grep -qE "AddressSanitizer.*(heap-buffer-overflow|SEGV)"; then echo " ok: a 1456-byte heap over-read was caught — sanitizer is real."elif [ "$rc" -eq 1 ]; then cat >&2 <<'MSG' SANITIZER SELF-TEST FAILED — THIS BUILD IS BLIND. A deliberate 1456-byte heap over-read was NOT detected, so this toolchain has no working AddressSanitizer. Any "FUZZ CLEAN" from it is meaningless: it cannot see heap-buffer-overflow, use-after-free, or leaks — the exact bug classes this gate exists to catch (the parser consumes UNTRUSTED pty bytes). A deliberate 1456-byte heap over-read was NOT detected, so this build has no working AddressSanitizer. Any "FUZZ CLEAN" from it is meaningless: it cannot see heap-buffer-overflow, use-after-free, or leaks — the exact bug classes this gate exists to catch (the parser consumes UNTRUSTED pty bytes). This is how the t-d4c7 scrollback over-read shipped through 5,000,000 "clean" iterations. This is not hypothetical: it is how the t-d4c7 scrollback over-read shipped through 5,000,000 "clean" iterations. Almost always one of: 1. you used zig -> it has no ASan runtime and drops it silently 2. you ran OUTSIDE the guix shell -> the runtime never engages Fix the toolchain; do NOT silence this check: - use a compiler with a real ASan runtime (system clang or gcc), e.g. ZIG=clang spike/fuzz.sh - verify with: nm build/san-selftest | grep __asan (must be non-empty) Fix by running the whole thing inside the manifest shell: guix shell -m ../sigil/manifest.scm -- spike/fuzz.sh Refusing to report a green from a gate that cannot fire. Do NOT silence this check. If you only want the UBSan coverage (which IS real — it caught the CSI param int overflow), run: SAN_UBSAN_ONLY=1 spike/fuzz.sh If you only want the UBSan coverage (which IS real — it caught the CSI param int overflow), run: SAN_UBSAN_ONLY=1 spike/fuzz.sh That is NOT the M2 memory-safety gate and must never be reported as one.MSG if [ "${SAN_UBSAN_ONLY:-0}" = "1" ]; then exit 1 fielse echo " self-test exited $rc (crashed for an unexpected reason)" >&2 echo " self-test exited $rc with no AddressSanitizer diagnostic." >&2 echo " That is neither a clean catch nor a clean miss — refusing to guess." >&2 printf '%s\n' "$san_out" | head -5 >&2 exit 1fiecho "== compiling vt-fuzz ==""$ZIG" cc -std=c99 -O1 -g -DVT_FUZZ \"$CC" -std=c99 -O1 -g -DVT_FUZZ \ -Wall -Wextra -Wno-unused-parameter \ $SAN_FLAGS \ native/vt-fuzz.c -o build/vt-fuzzspike/sanitizer-selftest.cmodified
/* Does the sanitizer build ACTUALLY detect a heap over-read? * * Exit 0 = the overflow was caught (the toolchain's ASan is real). * Exit 1 = NOT caught -> the "sanitizer" build is blind and any clean fuzz run * from it is meaningless. * CONTRACT (fuzz.sh depends on this): * killed by the sanitizer, printing an AddressSanitizer diagnostic * = CAUGHT. The toolchain's ASan is real. This process never * regains control, so it can NEVER exit 0 on success. * exit 1 = NOT caught. The build is blind, and any clean fuzz run from * it is meaningless. * * fuzz.sh matches the DIAGNOSTIC, not the exit status: a bare non-zero exit * could equally mean the binary failed to launch, which must never read as a * pass. * * WHY THIS EXISTS (t-d4c7). The M2 gate reported "5,000,000 iterations, * ASan/UBSan clean" for months while ASan WAS NOT PRESENT AT ALL. The pinned