fix: scrollback rows keep their push width (heap over-read); repair the gate
A history row is allocated at the cols in effect when it scrolled off and is never re-widthed (xterm no-rewrap). Every reader used a GUESSED width instead of the row's own, so widening the terminal after content scrolled into history read past the allocation:
nat_scrollback_row guessed t->cols wrong on any widening resize
nat_scrollback_runs guessed t->cols wrong on any widening resize
resize_grid pull guessed ocols wrong after TWO resizes (push@40 ->
80 -> 132 reads 80 cells from a
40-wide row)Reproduced: a 40-col terminal with 26 history rows, resized to 132, returns a 132-element row from a 40-wide allocation; 45 of the 92 out-of-bounds cells hold invalid codepoints. That is ~1.4KB of adjacent heap per row, and it gets RENDERED: in Slate it either crashes the frame (integer->char rejects the garbage, so take-frame dies and the pane stops updating) or, on a runs-based renderer, silently paints adjacent heap into the DOM. Reachable by ordinary use: run something that scrolls, widen the pane, scroll back. Confined to the app's own wasm linear memory (verified: no wasm trap, no cross-origin reach), but a native consumer such as the planned sigil-tui has no such sandbox.
Root cause is one thing, not three. The interpreted reference is safe by construction: resize-row reads (vector-length row), because a Scheme vector carries its length. Porting rows to raw int32t* dropped that length, and each reader then had to invent it. Fix: store the push width per ring entry (sbw) and make every reader use it. The comment asserting ring rows "are only read during pull (min-copied) - safe" is corrected; it stopped being true when the render seam added readers, and the pull's own min-copy was wrong anyway.
test/vt-test.sgl: three regression tests covering both readers and the pull. Each was confirmed to FAIL against the unfixed core before being trusted. One of them initially passed on the bug because it asserted per-RUN text length, and over-read garbage splits into many short runs; it now asserts the TOTAL.
THE GATE ITSELF WAS BLIND, which is why this shipped. spike/fuzz.sh reported "5,000,000 iterations, ASan/UBSan clean" with NO ASan present: the pinned zig has no ASan runtime, so -fsanitize=address alone fails to link (undefined asanreportload4) and -fsanitize=address,undefined links while silently dropping it (zero asan symbols in the binary). A 1456-byte heap over-read goes undetected. UBSan was real and useful; ASan was never there. Two fixes:
- vt-fuzz.c walked only sb[0] of a scrollback row (always in bounds). It now
walks the full row at sb_get_w. Necessary but NOT sufficient: with the full
walk pointed at t->cols, 150k iterations still passed on this toolchain,
because the missing ASan is the real reason the bug survived.
- fuzz.sh now self-tests BEFORE trusting itself: it compiles a known
cross-function heap over-read with the same flags and refuses to run if the
sanitizer fails to catch it. A naive self-test would pass on this broken
toolchain, because an in-function malloc lets UBSan's object-size check
fire and masquerade as ASan; the probe is deliberately opaque.
SAN_UBSAN_ONLY=1 keeps the real UBSan coverage available, loudly labelled,
never reportable as the M2 memory-safety gate. native/vt-fuzz.c | 23 +++++++++++++++++++++--
native/vt.c | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++-----------
spike/fuzz.sh | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
spike/sanitizer-selftest.c | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
test/vt-test.sgl | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 258 insertions(+), 19 deletions(-)native/vt-fuzz.cmodified
const int32_t *row = t->grid + (size_t)r * t->cols * 4; for (int c = 0; c < t->cols; c++) g_sink ^= row[c * 4]; } int32_t *sb = sb_get(t, rnd_below(t->sb_size ? t->sb_size + 1 : 1)); if (sb) g_sink ^= sb[0]; /* WALK A SCROLLBACK ROW IN FULL, at the width the real readers use. * This line used to be `if (sb) g_sink ^= sb[0];` — one cell, which is * ALWAYS in bounds. That is why 5,000,000 ASan iterations never saw the * heap over-read in t-d4c7: the harness had the right shape and stopped one * cell short. A history row keeps its push width, so reading it at t->cols * after a widening resize runs off the allocation — exactly what a full * walk catches on the first widening. * * SCOPE, honestly: walking at sb_get_w exercises the ring's width * BOOKKEEPING — a stale or too-large sb_w faults here under ASan. It does * NOT catch a reader reverting to t->cols, because the Sigil-glue readers * (nat_scrollback_row / nat_scrollback_runs) are compiled out of this * build by VT_FUZZ. That regression is covered by the Scheme-level test in * test/vt-test.sgl ("scrollback row keeps its push width"), which calls the * real readers. Two gates, different halves. */ int sbk = (int)rnd_below(t->sb_size ? t->sb_size + 1 : 1); int32_t *sb = sb_get(t, sbk); if (sb) { int w = sb_get_w(t, sbk); for (int c = 0; c < w; c++) g_sink ^= sb[c * 4]; } g_sink ^= color_256_rgb((int)rnd_below(300)); /* palette incl. out-of-range */}native/vt.cmodified
int curstyle; /* DECSCUSR 0..6 */ /* scrollback ring: index 0 = newest */ int32_t **sb; /* each entry: cols*4 int32 */ int32_t **sb; /* each entry: sb_w[i]*4 int32 */ int *sb_w; /* WIDTH each entry was pushed at. * A history row keeps the width it * had when it scrolled off (xterm * no-rewrap), which is NOT t->cols * after a resize. The interpreted * reference got this for free — * its rows are Scheme vectors that * carry their own length. Porting * to raw int32_t* dropped it, so * every reader guessed, and a * widening resize read off the end * of the allocation. Never read an * entry with anything but its * sb_w. */ int sb_head; /* index of newest */ int sb_size; int sb_cap; /* == sbmax */ copy = t->sb[oldest]; copy = (int32_t *)realloc(copy, cells * sizeof(int32_t)); t->sb[oldest] = copy; t->sb_w[oldest] = t->cols; t->sb_head = (t->sb_head + 1) % t->sb_cap; /* size stays == cap */ } else { copy = (int32_t *)malloc(cells * sizeof(int32_t)); t->sb_head = (t->sb_size == 0) ? 0 : (t->sb_head + 1) % t->sb_cap; t->sb[t->sb_head] = copy; t->sb_w[t->sb_head] = t->cols; t->sb_size++; } memcpy(copy, row, cells * sizeof(int32_t)); return t->sb[idx];}/* the width entry k was pushed at — the ONLY safe extent for reading it */static int sb_get_w(Vt *t, int k) { if (k < 0 || k >= t->sb_size) return 0; int idx = (t->sb_head - k + t->sb_cap) % t->sb_cap; return t->sb_w[idx];}/* pop the newest entry (for resize grow); returns its buffer (caller frees) */static int32_t *sb_pop(Vt *t) { if (t->sb_size == 0) return NULL;static int32_t *sb_pop(Vt *t, int *out_w) { if (t->sb_size == 0) { if (out_w) *out_w = 0; return NULL; } int32_t *row = t->sb[t->sb_head]; if (out_w) *out_w = t->sb_w[t->sb_head]; t->sb[t->sb_head] = NULL; t->sb_head = (t->sb_head - 1 + t->sb_cap) % t->sb_cap; t->sb_size--; int pull = main_screen ? (need < t->sb_size ? need : t->sb_size) : 0; /* pull rows out of scrollback into the top */ for (int k = 0; k < pull; k++) { int32_t *hist = sb_pop(t); /* newest first */ int hist_w = 0; int32_t *hist = sb_pop(t, &hist_w); /* newest first */ /* place newest adjacent to old top: slot (pull-1-k)?? term.sgl puts * the newest scrollback row at (pull-1) descending as it pops. */ int slot = pull - 1 - k; int32_t *dst = ng + (size_t)slot * cols * 4; int copy = (ocols < cols) ? ocols : cols; /* the history row's OWN width — NOT ocols. After two resizes * (push@40 -> 80 -> 132) ocols is 80 and the row is 40 wide. */ int copy = (hist_w < cols) ? hist_w : cols; for (int c = 0; c < copy; c++) memcpy(dst + c * 4, hist + c * 4, 4 * sizeof(int32_t)); free(hist); if (cols < 1) cols = 1; if (rows < 1) rows = 1; /* scrollback ring must be re-capacitied for new cols so sb_push copies the * right width; entries store the OLD width until resized through. We handle * width via the copy-min in resize_grid; ring buffers stay at old width but * are only read during pull (min-copied) — safe. */ /* Ring entries KEEP the width they were pushed at (xterm no-rewrap): they * are not re-widthed here, and must never be read at t->cols. Each entry's * width lives in sb_w — every reader uses it (nat_scrollback_row, * nat_scrollback_runs, and the resize pull in resize_grid). * * This comment previously claimed ring rows "are only read during pull * (min-copied) — safe". That stopped being true when the render seam added * readers, and the min-copy in the pull was itself wrong after two resizes * (it used ocols, not the row's width). Result: a heap over-read of up to * (t->cols - push_width) cells, rendered into the terminal. See t-d4c7. */ if (t->alt_active) { int32_t *nmain = resize_grid(t, t->main, orows, cols, rows, 0, 1); t->title = NULL; t->title_len = 0; t->title_cap = 0; t->sb_cap = sbmax > 0 ? sbmax : 1000; t->sb = (int32_t **)calloc(t->sb_cap, sizeof(int32_t *)); t->sb_w = (int *)calloc(t->sb_cap, sizeof(int)); t->sb_head = 0; t->sb_size = 0; t->dirty = (uint8_t *)calloc(rows, 1); t->alldirty = 1; free(t->sb[idx]); } free(t->sb); free(t->sb_w); for (int i = 0; i < t->nevents; i++) free(t->events[i].payload); free(t->events); free(t); int k = (int)sigil_as_fixnum(args[1]); int32_t *r = sb_get(t, k); if (!r) return SIGIL_FALSE; int cols = t->cols; /* the row's OWN width, not t->cols: a history row keeps the width it had * when it scrolled off. The interpreted reference returns the stored vector * verbatim, so its length IS the push width — match that exactly. */ int cols = sb_get_w(t, k); Value v = make_vec(vm, cols); sigil__gc_push_temp_root(vm, v); for (int c = 0; c < cols; c++) { if (!r) return SIGIL_EMPTY; int cursor_col = -1; if (argc >= 3 && sigil_is_fixnum(args[2])) cursor_col = (int)sigil_as_fixnum(args[2]); return row_runs_of(vm, r, t->cols, cursor_col); /* the row's OWN width, not t->cols — see sb_w. Cells past it would be blank * and right-trimmed away anyway, so this is byte-identical AND in-bounds. */ return row_runs_of(vm, r, sb_get_w(t, k), cursor_col);}/* ---- palette ------------------------------------------------------------- */spike/fuzz.shmodified
#!/usr/bin/env bash# M2 GATE: build + run the sigil-vt fuzz driver under ASan + UBSan.# 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.set -euo pipefailcd "$(dirname "$0")/.."# Any zig with the sanitizers works. Override if zig is not on PATH, e.g. to use# the Sigil monorepo's pinned toolchain: ZIG=../sigil/tools/zig/zig spike/fuzz.sh# 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}"ITERS="${1:-3000000}"SAN_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all"mkdir -p buildecho "== compiling vt-fuzz (ASan+UBSan) =="# ---------------------------------------------------------------------------# 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.# ---------------------------------------------------------------------------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-selftestset +eASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ ./build/san-selftest >/dev/null 2>&1rc=$?set -eif [ "$rc" -eq 0 ]; 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). This is not hypothetical: it is how the t-d4c7 scrollback over-read shipped through 5,000,000 "clean" iterations. 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) Refusing to report a green from a gate that cannot fire. 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 echo "" >&2 echo " SAN_UBSAN_ONLY=1 set — continuing WITHOUT memory-safety coverage." >&2 echo " This run CANNOT satisfy the M2 gate. Report it as UBSAN-ONLY." >&2 echo "" >&2 UBSAN_ONLY=1 else exit 1 fielse echo " self-test exited $rc (crashed for an unexpected reason)" >&2 exit 1fiecho "== compiling vt-fuzz ==""$ZIG" cc -std=c99 -O1 -g -DVT_FUZZ \ -Wall -Wextra -Wno-unused-parameter \ -fsanitize=address,undefined -fno-sanitize-recover=all \ $SAN_FLAGS \ native/vt-fuzz.c -o build/vt-fuzzecho "== running $ITERS iterations =="ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \ ./build/vt-fuzz "$ITERS"if [ "${UBSAN_ONLY:-0}" = "1" ]; then cat >&2 <<'MSG' ^ UBSAN-ONLY RUN. This did NOT check memory safety (no working ASan). It does not satisfy the M2 gate. Do not record it as "ASan/UBSan clean".MSGfispike/sanitizer-selftest.cadded
/* 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. * * 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 * zig ships no ASan runtime: `zig cc -fsanitize=address` alone fails to link * (undefined __asan_report_load4), and `-fsanitize=address,undefined` links but * SILENTLY DROPS ASan — the binary contains zero __asan symbols. Only UBSan * survived. A real heap over-read shipped straight through that green. * * The trap that makes this hard to notice: a naive self-test PASSES anyway. * If the malloc is visible in the same function, UBSan's __builtin_object_size * check fires and you conclude "sanitizers work". They don't — you measured * UBSan. So the allocation here is deliberately behind a noinline function, out * of the compiler's static reach, exactly like sb_push. Then ONLY ASan's heap * redzones can catch it. * * Keep this shape. If you "simplify" the malloc back into main(), this file * silently starts passing on a broken toolchain again. */#include <stdlib.h>#include <stdio.h>/* Opaque to __builtin_object_size — mirrors sb_push allocating a row. */__attribute__((noinline)) static int *make_block(int n) { int *p = (int *)malloc((size_t)n * sizeof(int)); if (!p) exit(2); for (int i = 0; i < n; i++) p[i] = i; return p;}static long sink = 0;int main(void) { /* 160 ints = 640 bytes: the same shape as a 40-column scrollback row. */ int *p = make_block(160); /* Read at int index 524 (byte 2096) — 1456 bytes past the end. This is * precisely the t-d4c7 over-read: a 40-wide row read at 132 columns. */ sink ^= p[524]; /* Reaching here means the sanitizer did NOT catch a 1456-byte heap * over-read. Print, then fail loudly. */ printf("sink=%ld\n", sink); free(p); return 1;}test/vt-test.sglmodified
(assert-true (vector-ref (cadr runs) 4)) (assert-equal "e" (vector-ref (cadr runs) 0)))) (test "blank row -> no runs" (assert-equal '() (vt-row-runs (vt-make 10 2) 1)))) (assert-equal '() (vt-row-runs (vt-make 10 2) 1))) ;; ---- t-d4c7: a history row keeps the width it was PUSHED at ------------- ;; Ring rows are allocated at the cols in effect when they scrolled off and ;; are never re-widthed (xterm no-rewrap). Reading one at t->cols after a ;; WIDENING resize runs off the end of the allocation: a heap over-read whose ;; garbage got rendered into the terminal (DoS via integer->char, plus ;; disclosure of adjacent heap). These call the REAL readers — the fuzz ;; harness cannot, since VT_FUZZ compiles the Sigil glue out. (test "scrollback row keeps its push width (no over-read on widen)" (let ((t (vt-make 40 3))) (vt-feed! t "aaaa\r\nbbbb\r\ncccc\r\ndddd\r\neeee\r\n") (assert-true (> (vt-scrollback-count t) 0)) (vt-resize! t 132 3) ; widen; history stays 40 wide (assert-equal 132 (vt-cols t)) ;; the row is returned at ITS width, not the grid's (assert-equal 40 (vector-length (vt-scrollback-row t 0))) ;; and every cell is a real codepoint, not heap garbage (let* ((row (vt-scrollback-row t 0)) (n (vector-length row))) (let loop ((i 0)) (when (< i n) (let ((cp (vt-cell-ch (vector-ref row i)))) (assert-true (and (>= cp 0) (<= cp 1114111)))) (loop (+ i 1))))))) (test "scrollback runs after widen stay in-bounds" (let ((t (vt-make 40 3))) (vt-feed! t "hello\r\nworld\r\nagain\r\nmore1\r\nmore2\r\n") (vt-resize! t 132 3) (let ((runs (vt-scrollback-runs t 0))) (assert-true (pair? runs)) ;; TOTAL rendered width must not exceed the row's real width. Asserting ;; per-RUN length instead would silently pass on the bug: over-read ;; garbage has erratic attrs, so it splits into many SHORT runs that are ;; each under the limit while the row as a whole runs far over. (let loop ((rs runs) (total 0)) (if (null? rs) (assert-true (<= total 40)) (loop (cdr rs) (+ total (string-length (vector-ref (car rs) 0))))))))) ;; The resize PULL had the same root cause with a different guess (ocols, not ;; t->cols) — only wrong after TWO resizes, when ocols is neither the push ;; width nor the new width. (test "resize pull uses the row's push width, not ocols" (let ((t (vt-make 40 3))) (vt-feed! t "aaaa\r\nbbbb\r\ncccc\r\ndddd\r\neeee\r\n") (vt-resize! t 80 3) ; ocols becomes 80... (vt-resize! t 132 8) ; ...but history rows are 40 wide (assert-equal 132 (vt-cols t)) ;; pulled-back rows must be real content, not garbage (let loop ((r 0)) (when (< r 8) (let* ((row (vt-row-cells t r)) (n (vector-length row))) (let loop2 ((i 0)) (when (< i n) (let ((cp (vt-cell-ch (vector-ref row i)))) (assert-true (and (>= cp 0) (<= cp 1114111)))) (loop2 (+ i 1))))) (loop (+ r 1)))))))