Commited38b06aRecorded17 Jul 2026Repositorysigil-vt
fix: free t->dirty; point the gate at the manifest gcc (real ASan)
Message
Two things the now-working sanitizer found immediately.
- t->dirty was never freed. vt_new callocs it, every term_resize reallocs it, and vt_free 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).
detect_leaks=1has been set in the gate from the start and would have caught it on day one; it reported nothing because ASan was never linked.
- 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.
Changed
native/vt.c | 7 +++++++
spike/fuzz.sh | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------
spike/sanitizer-selftest.c | 13 ++++++++++---
3 files changed, 67 insertions(+), 37 deletions(-)Diff
native/vt.cmodified
@@ -1243,6 +1243,13 @@ static void vt_free(void *data) {
1243
} 1244
free(t->sb); 1245
free(t->sb_w);+1246
/* t->dirty is allocated in vt_new and realloc'd by every term_resize, and+1247
* was never freed: EVERY emulator destroyed leaked its dirty array (rows+1248
* bytes — unbounded across a session that opens and closes terminals).+1249
* `detect_leaks=1` has been set in the fuzz gate from the start and would+1250
* have caught this on day one; it reported nothing because ASan was never+1251
* linked (t-d4c7). Found within seconds of the gate being able to see. */+1252
free(t->dirty); 1253
for (int i = 0; i < t->nevents; i++) free(t->events[i].payload); 1254
free(t->events); 1255
free(t);spike/fuzz.shmodified
@@ -1,61 +1,75 @@
1
#!/usr/bin/env bash 2
# M2 GATE: build + run the sigil-vt fuzz driver under the sanitizers. 3
# The pure C core (native/vt.c under -DVT_FUZZ) is standalone — no libsigil.+4
#+5
# RUN IT LIKE THIS (compiler AND runtime must both come from the manifest):+6
#+7
# guix shell -m ../sigil/manifest.scm -- spike/fuzz.sh+8
#+9
# The monorepo manifest already carries gcc-toolchain, whose ASan is REAL.+10
# Two ways to end up with a silently blind gate — both caught by the self-test+11
# below, neither obvious from the output:+12
#+13
# 1. THE PINNED ZIG HAS NO ASan RUNTIME. `zig cc -fsanitize=address` alone+14
# fails to link (undefined __asan_report_load4); with `address,undefined`+15
# it links and SILENTLY DROPS ASan (zero __asan symbols in the binary) and+16
# still prints FUZZ CLEAN. gcc keeps ASan with the same flag combo — the+17
# silent drop is zig-specific. Do not use zig for this gate.+18
# 2. RUNNING OUTSIDE THE GUIX SHELL. The same gcc-built binary does NOT fault+19
# when run outside the shell — it exits cleanly, looking exactly like a+20
# pass. Compile AND run inside.+21
#+22
# This is not hypothetical: a real heap over-read (t-d4c7) shipped through+23
# 5,000,000 "clean" iterations of this gate. 24
set -euo pipefail 25
cd "$(dirname "$0")/.."−6
# Any toolchain with a REAL AddressSanitizer runtime. Override if zig is not on−7
# PATH, e.g. the Sigil monorepo's pinned toolchain:−8
# ZIG=../sigil/tools/zig/zig spike/fuzz.sh−9
# NOTE: the pinned zig currently ships NO ASan runtime — see the self-test.−10
ZIG="${ZIG:-zig}"+26
CC="${CC:-gcc}" 27
ITERS="${1:-3000000}" 28
SAN_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all" 29
mkdir -p build 30
31
# --------------------------------------------------------------------------- 32
# STEP 0 — PROVE THE GATE CAN FIRE, BEFORE TRUSTING ANYTHING IT SAYS.−17
#−18
# t-d4c7: this gate reported "5,000,000 iterations, ASan/UBSan clean" while−19
# ASan was NOT LINKED AT ALL, and a real heap over-read shipped through it.−20
# `zig cc -fsanitize=address` alone fails to link (undefined __asan_report_*);−21
# with `address,undefined` it links and SILENTLY DROPS ASan (the binary has−22
# zero __asan symbols). The run still prints CLEAN — which is worse than having−23
# no gate, because it reads as assurance.−24
#−25
# So: compile a KNOWN heap over-read with the SAME flags and require it to be−26
# caught. A gate that cannot demonstrate it fires is not a gate.+33
# Compile a KNOWN heap over-read with the SAME compiler and flags, and require+34
# it to be caught. A gate that cannot demonstrate it fires is not a gate. This+35
# check covers BOTH failure modes above without having to detect either. 36
# --------------------------------------------------------------------------- 37
echo "== sanitizer self-test (can this build detect a heap over-read?) =="−29
"$ZIG" cc -std=c99 -O1 -g $SAN_FLAGS spike/sanitizer-selftest.c -o build/san-selftest+38
"$CC" -std=c99 -O1 -g $SAN_FLAGS spike/sanitizer-selftest.c -o build/san-selftest 39
set +e−31
ASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \−32
./build/san-selftest >/dev/null 2>&1+40
san_out="$(ASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \+41
./build/san-selftest 2>&1)" 42
rc=$? 43
set -e−35
if [ "$rc" -eq 0 ]; then+44
# Match the DIAGNOSTIC, not the exit code. A caught over-read ABORTS (SIGABRT,+45
# rc 134) — it can never exit 0 — while a bare non-zero exit could just mean the+46
# binary failed to launch. Only an actual ASan report proves the gate fired.+47
if printf '%s' "$san_out" | grep -qE "AddressSanitizer.*(heap-buffer-overflow|SEGV)"; then 48
echo " ok: a 1456-byte heap over-read was caught — sanitizer is real." 49
elif [ "$rc" -eq 1 ]; then 50
cat >&2 <<'MSG' 51
52
SANITIZER SELF-TEST FAILED — THIS BUILD IS BLIND. 53
−42
A deliberate 1456-byte heap over-read was NOT detected, so this toolchain has−43
no working AddressSanitizer. Any "FUZZ CLEAN" from it is meaningless: it−44
cannot see heap-buffer-overflow, use-after-free, or leaks — the exact bug−45
classes this gate exists to catch (the parser consumes UNTRUSTED pty bytes).+54
A deliberate 1456-byte heap over-read was NOT detected, so this build has no+55
working AddressSanitizer. Any "FUZZ CLEAN" from it is meaningless: it cannot+56
see heap-buffer-overflow, use-after-free, or leaks — the exact bug classes+57
this gate exists to catch (the parser consumes UNTRUSTED pty bytes).+58
+59
This is how the t-d4c7 scrollback over-read shipped through 5,000,000+60
"clean" iterations. 61
−47
This is not hypothetical: it is how the t-d4c7 scrollback over-read shipped−48
through 5,000,000 "clean" iterations.+62
Almost always one of:+63
1. you used zig -> it has no ASan runtime and drops it silently+64
2. you ran OUTSIDE the guix shell -> the runtime never engages 65
−50
Fix the toolchain; do NOT silence this check:−51
- use a compiler with a real ASan runtime (system clang or gcc), e.g.−52
ZIG=clang spike/fuzz.sh−53
- verify with: nm build/san-selftest | grep __asan (must be non-empty)+66
Fix by running the whole thing inside the manifest shell:+67
guix shell -m ../sigil/manifest.scm -- spike/fuzz.sh 68
−55
Refusing to report a green from a gate that cannot fire.+69
Do NOT silence this check. 70
−57
If you only want the UBSan coverage (which IS real — it caught the CSI−58
param int overflow), run: SAN_UBSAN_ONLY=1 spike/fuzz.sh+71
If you only want the UBSan coverage (which IS real — it caught the CSI param+72
int overflow), run: SAN_UBSAN_ONLY=1 spike/fuzz.sh 73
That is NOT the M2 memory-safety gate and must never be reported as one. 74
MSG 75
if [ "${SAN_UBSAN_ONLY:-0}" = "1" ]; then@@ -68,12 +82,14 @@ MSG
82
exit 1 83
fi 84
else−71
echo " self-test exited $rc (crashed for an unexpected reason)" >&2+85
echo " self-test exited $rc with no AddressSanitizer diagnostic." >&2+86
echo " That is neither a clean catch nor a clean miss — refusing to guess." >&2+87
printf '%s\n' "$san_out" | head -5 >&2 88
exit 1 89
fi 90
91
echo "== compiling vt-fuzz =="−76
"$ZIG" cc -std=c99 -O1 -g -DVT_FUZZ \+92
"$CC" -std=c99 -O1 -g -DVT_FUZZ \ 93
-Wall -Wextra -Wno-unused-parameter \ 94
$SAN_FLAGS \ 95
native/vt-fuzz.c -o build/vt-fuzzspike/sanitizer-selftest.cmodified
@@ -1,8 +1,15 @@
1
/* Does the sanitizer build ACTUALLY detect a heap over-read? 2
*−3
* Exit 0 = the overflow was caught (the toolchain's ASan is real).−4
* Exit 1 = NOT caught -> the "sanitizer" build is blind and any clean fuzz run−5
* from it is meaningless.+3
* CONTRACT (fuzz.sh depends on this):+4
* killed by the sanitizer, printing an AddressSanitizer diagnostic+5
* = CAUGHT. The toolchain's ASan is real. This process never+6
* regains control, so it can NEVER exit 0 on success.+7
* exit 1 = NOT caught. The build is blind, and any clean fuzz run from+8
* it is meaningless.+9
*+10
* fuzz.sh matches the DIAGNOSTIC, not the exit status: a bare non-zero exit+11
* could equally mean the binary failed to launch, which must never read as a+12
* pass. 13
* 14
* WHY THIS EXISTS (t-d4c7). The M2 gate reported "5,000,000 iterations, 15
* ASan/UBSan clean" for months while ASan WAS NOT PRESENT AT ALL. The pinned