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.

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.

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-fuzz
spike/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