Released17 Jul 2026Commit12f7023fLine0.1.xLateston its line

v0.1.1

sigil-vt 0.1.1: scrollback heap over-read, dirty-array leak, and a gate that could not see either

Notes

Two memory-safety bugs, both present in 0.1.0, and the reason they shipped.

1. HEAP OVER-READ READING SCROLLBACK AFTER A WIDENING RESIZE

A history row is allocated at the column count in effect when it scrolled off and is never re-widthed (xterm no-rewrap semantics). Three readers ignored that and used a guessed width instead: natscrollbackrow and natscrollbackruns read t->cols (wrong after any widening resize), and the pull path in resize_grid read ocols (wrong after two resizes, where ocols is neither the row's width nor the new one). Reading a 40 column row at 132 columns runs about 1.4KB past the allocation.

The garbage is rendered. A consumer that validates codepoints crashes its frame (Slate's take-frame dies and the pane stops updating); a consumer that does not paints adjacent heap into its output. Reachable by ordinary use: run something that scrolls, widen the terminal, scroll back. No hostile input required, though the parser does consume untrusted pty bytes, so this sat on the wrong side of the trust boundary. Under wasm the read stays inside the module's own linear memory; a native consumer has no such sandbox.

The root cause is one thing, not three. The interpreted reference this core was ported from is safe by construction, because its rows are Scheme vectors and resize-row simply reads (vector-length row). Porting rows to raw int32t pointers dropped the length that a vector carries for free, so every reader had to invent it, and each invented it differently. Fixed by storing the push width per ring entry (sbw) and making every reader use it. The comment asserting that 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 regardless.

2. t->dirty WAS NEVER FREED

vtnew allocates it, every termresize reallocates it, and vt_free releases eight other fields while missing that one. Every emulator destroyed leaked its dirty array, unbounded across a session that opens and closes terminals.

3. THE M2 SANITIZER GATE WAS BLIND, WHICH IS WHY BOTH SHIPPED

The gate reported "5,000,000 iterations, ASan/UBSan clean, no leaks" with no AddressSanitizer present at all. The pinned zig ships no ASan runtime: -fsanitize=address alone fails to link (undefined asanreportload4), and -fsanitize=address,undefined links while silently dropping it, leaving zero asan symbols in the binary and still printing a clean result. UBSan was real throughout and did useful work (it caught a CSI parameter integer overflow); ASan was never there. detect_leaks=1 was set from the start and would have caught the leak on day one, but the leak checker lives in the ASan runtime, so it was inert too.

The gate now uses the monorepo manifest's gcc-toolchain, whose ASan is real and which keeps it under the same flag combination (the silent drop is zig-specific). Run it with:

  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, or running the gcc-built binary outside the guix shell, where it exits cleanly on a deliberate over-read and looks exactly like a pass. So spike/fuzz.sh now proves it can fire before trusting itself: it compiles a known cross-function heap over-read with the same compiler and flags and refuses to run unless the build catches it. That covers both failure modes without having to detect either. SANUBSANONLY=1 keeps the genuine UBSan coverage available, loudly labelled, never reportable as the memory-safety gate.

vt-fuzz.c also walked only the first cell of a scrollback row, which is always in bounds; it now walks the full row. That gap was real but not the reason the bug survived: with the full walk pointed at the buggy assumption, 150,000 iterations still passed on the blind toolchain. The missing ASan is why it did not matter.

VERIFICATION

test/vt-test.sgl: 98 passing, including three regression tests covering both readers and the resize pull. Each was confirmed to fail against the unfixed core before being trusted; one initially passed on the bug, because it asserted per-run text length and over-read garbage splits into many short runs, so it now asserts the total.

M2 gate on this commit, under the manifest gcc, with the self-test verifying the sanitizer fires: corpus replay plus 3,000,000 mutation iterations, leak detection on, clean.

Commit

12f7023f

The commit this tag names.

Releases