okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

Kieran Klukasfix(conformance): keep decoding after an op a zero-height screen cannot take5e21978

upstream
21.5 KiB610 linesraw
1// Package conformance drives the renderer against real terminal emulators and
2// compares the resulting screen against ground truth.
3//
4// The renderer keeps a model of what the terminal is currently showing and
5// writes only the difference between that model and the next frame. When the
6// model drifts out of step with reality the output is still valid ANSI and
7// still looks plausible; it just paints the wrong thing. Asserting on escape
8// sequences cannot catch that, because there is no single correct sequence to
9// compare against.
10//
11// So these tests compare against ground truth instead: run a sequence of
12// frames incrementally on one terminal, paint the final frame from scratch on
13// another, and require the two screens to match. Any disagreement is a bug in
14// the incremental path.
15//
16// This lives in its own module because the reference emulator is a cgo package
17// with a newer Go requirement, and neither cost belongs in the library that
18// ships to users.
19package conformance
20
21import (
22	"fmt"
23	"strings"
24
25	"github.com/charmbracelet/x/ansi"
26)
27
28// The fuzzer needs to explore sequences of renderer operations, not just
29// strings of text, because the bugs worth finding live in the interaction
30// between frames. A single frame is nearly always painted correctly; it is the
31// second frame, drawn as a diff against a model of the first, that goes wrong.
32//
33// So a fuzz input is decoded as a little program: a screen size, a set of
34// renderer options, then a list of operations to run in order. Go's fuzzer
35// mutates the raw bytes and coverage feedback steers it toward programs that
36// reach new code in the renderer. That is the part a hand-written table of
37// cases cannot do, since interesting programs are combinations nobody thinks
38// to write down.
39
40// Op is a single step in a fuzz program.
41type Op struct {
42	// Kind selects which operation to perform.
43	Kind OpKind
44
45	// Y is the row an OpDrawLine writes to, already reduced modulo the screen
46	// height so it is always in range.
47	Y int
48
49	// Text is the content an OpDrawLine writes.
50	Text string
51
52	// N is a small integer argument, used by operations like OpMoveTo.
53	N int
54
55	// W and H are the new dimensions for an OpResize, already clamped to the
56	// valid range.
57	W, H int
58}
59
60// OpKind enumerates the renderer operations a fuzz program can perform.
61type OpKind uint8
62
63const (
64	// OpDrawLine writes Text into row Y of the pending frame.
65	OpDrawLine OpKind = iota
66
67	// OpClear blanks the pending frame.
68	OpClear
69
70	// OpRender flushes the pending frame through the renderer's incremental
71	// path. This is the operation under test; every other op exists to get
72	// the renderer into an interesting state before one of these.
73	OpRender
74
75	// OpRedraw forces a full repaint, which should always resynchronise the
76	// renderer's model with the terminal.
77	OpRedraw
78
79	// OpMoveTo repositions the cursor, which is a common source of drift
80	// because it is where a wrong column model becomes a wrong write.
81	OpMoveTo
82
83	// OpResize changes the terminal and buffer dimensions to W by H. Resizing
84	// is the biggest geometry change a terminal undergoes, and a renderer that
85	// carries stale geometry across one will paint the wrong thing, so it is
86	// worth fuzzing directly.
87	OpResize
88
89	// OpErase marks the screen to be repainted on the next render, without
90	// performing that render. OpRedraw does both at once and so can never be
91	// separated from what happens in between; an erase left pending across a
92	// resize is a different thing entirely, and the one applications drawing
93	// inline frames actually do when the frame changes shape.
94	OpErase
95
96	opKindCount
97)
98
99// opTable maps an input byte onto an operation. Its length is fixed and never
100// changes, so adding a kind rewrites only the slots it is given: every corpus
101// entry minted before it keeps decoding to the program it was minimised for.
102// Choosing the kind by [OpKind] count instead made every saved entry mean
103// something else the moment an op was added, silently, while still passing.
104//
105// TestCorpusDecodesStably pins what the saved entries decode to, so a change
106// here fails loudly and the entries can be re-minted on purpose.
107var opTable = [16]OpKind{
108	OpDrawLine, OpClear, OpRender, OpRedraw,
109	OpMoveTo, OpResize, OpErase, OpDrawLine,
110	OpClear, OpRender, OpRedraw, OpMoveTo,
111	OpResize, OpErase, OpDrawLine, OpRender,
112}
113
114// opByte is the input byte that decodes to kind. A hand-written seed goes
115// through this rather than using the enum's numeric value, which stopped being
116// the same thing when the decoder moved to a fixed slot table.
117func opByte(kind OpKind) byte {
118	for slot, k := range opTable {
119		if k == kind {
120			return byte(slot)
121		}
122	}
123	panic("opTable does not name " + kind.String())
124}
125
126// Resize bounds. Screens stay small so failures stay readable, but the range
127// is wide enough to shrink below and grow above the starting size, which is
128// where stale-geometry bugs show up. Exported so the decoder's own tests can
129// check that every OpResize lands inside them.
130const (
131	MinResizeW, MaxResizeW = 4, 32
132	MinResizeH, MaxResizeH = 2, 8
133)
134
135// InlineRowsAbove is how many rows of someone else's output sit above an inline
136// frame. They are the point, not scenery: an erase that reaches one row too far
137// lands in them, and against a blank screen that mistake reads back as blanks
138// and never shows.
139const InlineRowsAbove = 2
140
141// InlineSeedWidth is how many columns each of those rows uses. It has to fit the
142// narrowest screen a program can ask for, or the text wraps, the rows above take
143// more rows than they were given, and the frame no longer starts where
144// InlineRowsAbove says it does.
145const InlineSeedWidth = 2
146
147// InlineTermHeight is the terminal height an inline program runs against. It
148// leaves a row below the tallest frame: a frame reaching the last row scrolls on
149// the next newline, and a scrolled screen sits at a different absolute row in
150// every run, which a differential comparison would report as a bug.
151const InlineTermHeight = InlineRowsAbove + MaxResizeH + 2
152
153// String makes failures readable, since a raw OpKind number tells you nothing
154// about what the renderer was asked to do.
155func (k OpKind) String() string {
156	switch k {
157	case OpDrawLine:
158		return "DrawLine"
159	case OpClear:
160		return "Clear"
161	case OpRender:
162		return "Render"
163	case OpRedraw:
164		return "Redraw"
165	case OpMoveTo:
166		return "MoveTo"
167	case OpResize:
168		return "Resize"
169	case OpErase:
170		return "Erase"
171	default:
172		return fmt.Sprintf("OpKind(%d)", uint8(k))
173	}
174}
175
176// Program is a decoded fuzz input: a terminal size, renderer settings, and the
177// operations to run.
178type Program struct {
179	// Width and Height are the terminal size, clamped to a small range. Small
180	// screens are deliberate: they make wrapping and scrolling happen often,
181	// and they keep failure output readable.
182	Width, Height int
183
184	// GraphemeWidth selects the renderer's width model, and is matched by the
185	// emulator's own mode so both sides agree or disagree deliberately rather
186	// than by accident.
187	GraphemeWidth bool
188
189	// ScrollOptim enables the hardware-scroll path, which is worth fuzzing
190	// separately because it moves glyphs bodily and carries their painted
191	// widths with them.
192	ScrollOptim bool
193
194	// Inline draws the frame inline instead of over the whole screen, the way
195	// a prompt or a progress display does. It is a different renderer
196	// entirely in the ways that matter here: the frame is shorter than the
197	// terminal, so the model cannot assume it owns every row, and the cursor
198	// moves relatively, so there is no absolute move to fall back on when the
199	// model loses track of where it is. Bugs that need those conditions are
200	// invisible to a fullscreen-only harness.
201	Inline bool
202
203	// Ops are the operations to run in order.
204	Ops []Op
205}
206
207// String renders a program as reproducible Go-ish pseudocode. A fuzz failure is
208// only useful if you can see what it did, and a hex dump of the input does not
209// tell you that.
210func (p Program) String() string {
211	var b strings.Builder
212	fmt.Fprintf(&b, "size=%dx%d graphemeWidth=%v scrollOptim=%v inline=%v\n",
213		p.Width, p.Height, p.GraphemeWidth, p.ScrollOptim, p.Inline)
214	for i, op := range p.Ops {
215		switch op.Kind {
216		case OpDrawLine:
217			fmt.Fprintf(&b, "  %2d. DrawLine(y=%d, %q)\n", i, op.Y, op.Text)
218		case OpMoveTo:
219			fmt.Fprintf(&b, "  %2d. MoveTo(%d)\n", i, op.N)
220		case OpResize:
221			fmt.Fprintf(&b, "  %2d. Resize(%dx%d)\n", i, op.W, op.H)
222		default:
223			fmt.Fprintf(&b, "  %2d. %s\n", i, op.Kind)
224		}
225	}
226	return b.String()
227}
228
229// corpusAlphabet is the set of clusters a fuzz program can draw. The fuzzer
230// picks from this by index rather than consuming raw UTF-8, because letting it
231// invent its own bytes wastes almost all its time on invalid encodings and
232// widthless control characters. Choosing from a curated set means every
233// generated line is made of clusters that actually stress the width model.
234//
235// The clusters are grouped by width behaviour. Everything from
236// driftClusterOffset onward is a cluster where legacy wcwidth and grapheme
237// width disagree, and those are the ones that provoke column drift. The
238// clusters before that point exercise distinct code paths (combining marks,
239// text presentation, a different wide script) without drifting, so the fuzzer
240// explores them too.
241var corpusAlphabet = []string{
242	// Narrow ASCII.
243	"a", "b", "c", "X", " ", ".", "|", "#",
244	// Wide CJK.
245	"世", "界",
246	// Wide Hangul, a different script in the same width class.
247	"가", "한",
248	// Plain emoji, width 2, no modifiers.
249	"\U0001fae0", // melting face
250	// Combining marks: a base plus a zero-width combiner, width 1 total.
251	"e\u0301", // é
252	"n\u0303", // ñ
253	// VS15 text presentation, forces narrow rendering, width 1.
254	"\u2639\ufe0e", // frowning face, text style
255	// ZWJ sequences where both width models agree at 2.
256	"\U0001f469\u200d\U0001f4bb", // woman technologist
257	"\U0001f426\u200d\U0001f525", // phoenix
258	// Skin tone modifiers. Legacy wcwidth measures these at 4, two columns for
259	// the base and two for the modifier, which is what ghostty's legacy mode
260	// paints. They sit before driftClusterOffset only so the seeds stay pointed
261	// at the sequences terminals disagree about; the fuzzer still reaches them,
262	// and a four-column cluster is worth reaching.
263	"\U0001f44d\U0001f3fd", // thumbs up, medium skin
264	"\U0001f44b\U0001f3ff", // waving hand, dark skin
265
266	// --- drift-prone clusters below: legacy wcwidth != grapheme width ---
267
268	// VS16 emoji presentation: legacy says 1, grapheme says 2.
269	"\u2639\ufe0f", // frowning face
270	"\u2764\ufe0f", // heart
271	"\u2708\ufe0f", // airplane
272	// ZWJ with VS16: legacy says 1, grapheme says 2.
273	"\u26d3\ufe0f\u200d\U0001f4a5", // broken chain
274	// Regional indicators: legacy width varies wildly across terminals,
275	// grapheme says 2. A single unpaired indicator is the most ambiguous case.
276	"\U0001f1fa",           // single regional indicator U
277	"\U0001f1fa\U0001f1f8", // flag US
278	"\U0001f1ef\U0001f1f5", // flag JP
279	// Keycap: legacy says 1, grapheme says 2.
280	"1\ufe0f\u20e3", // keycap 1
281}
282
283// driftClusterOffset is where the drift-prone clusters begin in corpusAlphabet.
284// Everything from here on is a VS16, ZWJ, regional-indicator, or keycap sequence
285// where legacy wcwidth and grapheme width disagree.
286const driftClusterOffset = 20
287
288// driftIndices are the positions in corpusAlphabet whose clusters legacy
289// wcwidth and grapheme width disagree about.
290//
291// Computed rather than written down, because the width tables underneath move
292// with Unicode releases. A cluster that provokes drift today may not tomorrow,
293// and a hardcoded list would keep pointing at it while quietly testing nothing.
294// TestDriftPremise fails if the list ever comes out empty.
295func driftIndices() []int {
296	var idx []int
297	for i := driftClusterOffset; i < len(corpusAlphabet); i++ {
298		c := corpusAlphabet[i]
299		if ansi.StringWidthWc(c) != ansi.StringWidth(c) {
300			idx = append(idx, i)
301		}
302	}
303	return idx
304}
305
306// DriftClusters are the clusters that legacy wcwidth and grapheme width
307// disagree about, and so the ones that provoke column drift.
308func DriftClusters() []string {
309	var out []string
310	for _, i := range driftIndices() {
311		out = append(out, corpusAlphabet[i])
312	}
313	return out
314}
315
316// CorpusAlphabet returns the clusters a fuzz program can draw, for tests that
317// need to reason about the corpus.
318func CorpusAlphabet() []string {
319	return append([]string(nil), corpusAlphabet...)
320}
321
322// Seeds is the starting corpus for the fuzz targets.
323//
324// Seeds matter more than they look. Coverage-guided fuzzing explores outward
325// from what it is given, and random bytes mostly decode into programs that draw
326// plain ASCII and never reach the width-handling code at all. These put the
327// fuzzer inside the interesting region immediately: each one draws a
328// drift-provoking cluster and renders more than once, so mutation starts from
329// programs that already exercise the incremental path against awkward content.
330func Seeds() [][]byte {
331	// endOfLine is the alphabet index that terminates a drawn line.
332	endOfLine := byte(len(corpusAlphabet))
333
334	var seeds [][]byte
335	for _, i := range driftIndices() {
336		cluster := byte(i)
337
338		// Cover both width models crossed with the scroll path and the frame
339		// mode, since a bug can hide in any one of the combinations.
340		for mode := byte(0); mode < 8; mode++ {
341			seeds = append(seeds, []byte{
342				14, 2, // 20x4
343				mode,
344				// Two adjacent clusters, then render: drift accumulates across
345				// neighbours, so adjacency is the productive case.
346				opByte(OpDrawLine), 0, cluster, cluster, endOfLine,
347				opByte(OpRender),
348				// A second frame, so the renderer has to diff rather than
349				// paint, which is where the interesting bugs live.
350				opByte(OpDrawLine), 1, cluster, 0, cluster, endOfLine,
351				opByte(OpRender),
352				// Shrink row 0, which leaves residue behind if the renderer's
353				// column model is wrong.
354				opByte(OpDrawLine), 0, 0, cluster, endOfLine,
355				opByte(OpRender),
356			})
357		}
358	}
359
360	// Structural seeds, so the fuzzer does not have to discover Clear, Redraw
361	// and MoveTo by chance. They still draw drifting clusters, so they explore
362	// those operations somewhere worth being.
363	if drift := driftIndices(); len(drift) > 0 {
364		first := byte(drift[0])
365		last := byte(drift[len(drift)-1])
366
367		seeds = append(seeds,
368			[]byte{
369				14, 2, 0,
370				opByte(OpDrawLine), 0, first, last, 8, endOfLine,
371				opByte(OpRender),
372				opByte(OpClear),
373				opByte(OpRender),
374				opByte(OpRedraw),
375			},
376			[]byte{
377				10, 1, 1,
378				opByte(OpDrawLine), 0, last, last, endOfLine,
379				opByte(OpRender),
380				opByte(OpMoveTo), 3,
381				opByte(OpDrawLine), 0, 0, endOfLine,
382				opByte(OpRender),
383			},
384		)
385
386		// Resize seeds. Resizing is the biggest geometry change a terminal
387		// undergoes, and the interesting failures come from carrying stale
388		// geometry across one, so each seed draws, resizes, and draws again.
389		// The dimensions are encoded relative to the resize bounds: a byte b
390		// maps to minResize + b % (max-min+1).
391		// The decoder maps a byte to a size within the resize bounds, so a seed
392		// that wants a particular size has to encode it the same way. An inline
393		// program may collapse to no rows, so its heights start from zero and
394		// the same byte means a different height in each mode; pass the low
395		// bound the target mode uses.
396		resizeW := func(w int) byte { return byte((w - MinResizeW) % (MaxResizeW - MinResizeW + 1)) }
397		resizeH := func(h, lo int) byte { return byte((h - lo) % (MaxResizeH - lo + 1)) }
398		for mode := byte(0); mode < 8; mode++ {
399			// An inline frame may collapse to nothing, so its heights decode
400			// from a lower bound. Passing the fullscreen bound for all eight
401			// modes silently shifted every inline resize in these seeds.
402			lo := MinResizeH
403			if mode&4 != 0 {
404				lo = 0
405			}
406			seeds = append(seeds, []byte{
407				14, 2, // 20x4
408				mode,
409				opByte(OpDrawLine), 0, first, first, last, last, endOfLine,
410				opByte(OpRender),
411				// Shrink, then draw a line that only fits the smaller screen.
412				opByte(OpResize), resizeW(8), resizeH(3, lo),
413				opByte(OpDrawLine), 0, first, 0, endOfLine,
414				opByte(OpRender),
415				// Grow back, which is where clipped content can leave residue.
416				opByte(OpResize), resizeW(24), resizeH(6, lo),
417				opByte(OpDrawLine), 0, last, last, last, endOfLine,
418				opByte(OpRender),
419			})
420		}
421
422		// An inline frame that changes shape. The erase is left pending across
423		// the resize, which is what an application does when its view swaps for
424		// a shorter one, and it is the case a single OpRedraw cannot express.
425		// Where the erase starts from decides how much of the taller frame it
426		// reaches, so a renderer that has lost track of the cursor leaves the
427		// top of the old frame on screen.
428		seeds = append(seeds, []byte{
429			14, 5, // 20x7
430			4, // inline
431			opByte(OpDrawLine), 0, first, last, endOfLine,
432			opByte(OpDrawLine), 4, first, endOfLine,
433			opByte(OpRender),
434			opByte(OpErase),
435			opByte(OpResize), resizeW(20), resizeH(2, 0),
436			opByte(OpDrawLine), 0, last, endOfLine,
437			opByte(OpRender),
438		})
439	}
440
441	return seeds
442}
443
444// decoder hands out small values from the fuzzer's byte string. Running off the
445// end is normal and expected: the fuzzer supplies inputs of every length, so
446// the decoder reports exhaustion rather than treating a short input as an
447// error, and the caller simply stops building the program.
448type decoder struct {
449	buf []byte
450	pos int
451}
452
453// next returns the next byte, or false once the input is exhausted.
454func (d *decoder) next() (byte, bool) {
455	if d.pos >= len(d.buf) {
456		return 0, false
457	}
458	b := d.buf[d.pos]
459	d.pos++
460	return b, true
461}
462
463// intn returns a value in [0,n), or false if the input is exhausted. Taking one
464// byte per value keeps the mapping from input bytes to program structure simple
465// enough that the fuzzer's byte-level mutations translate into small, targeted
466// program changes, which is what makes coverage feedback effective.
467func (d *decoder) intn(n int) (int, bool) {
468	b, ok := d.next()
469	if !ok || n <= 0 {
470		return 0, false
471	}
472	return int(b) % n, true
473}
474
475// intrange returns a value in [lo,hi], or false if the input is exhausted.
476func (d *decoder) intrange(lo, hi int) (int, bool) {
477	v, ok := d.intn(hi - lo + 1)
478	if !ok {
479		return 0, false
480	}
481	return lo + v, true
482}
483
484// DecodeProgram interprets fuzzer bytes as a program.
485//
486// Every byte string maps to some valid program, and the decoder never fails.
487// That matters: if malformed inputs were rejected the fuzzer would spend its
488// budget rediscovering the input format instead of exploring renderer
489// behaviour.
490func DecodeProgram(data []byte) Program {
491	d := &decoder{buf: data}
492
493	// Sizes stay small and odd-ish so wrapping and scrolling are common. A
494	// wide cluster landing on the last column is one of the most productive
495	// cases, and narrow screens hit it constantly.
496	w, _ := d.intn(24)
497	h, _ := d.intn(6)
498	p := Program{Width: w + 6, Height: h + 2}
499
500	if b, ok := d.next(); ok {
501		// Independent bits, so the fuzzer can flip the width model, the scroll
502		// path or the frame mode without disturbing the rest of the program.
503		p.GraphemeWidth = b&1 != 0
504		p.ScrollOptim = b&2 != 0
505		p.Inline = b&4 != 0
506	}
507
508	// Bounded so a single input cannot run for an unreasonable time; the
509	// fuzzer is far more effective with many short programs than a few long
510	// ones.
511	const maxOps = 24
512
513	// The live dimensions, which an OpResize changes. DrawLine and MoveTo
514	// bounds are computed from these so a line drawn after a resize is always
515	// valid for the current screen, not the one the program started with.
516	curW, curH := p.Width, p.Height
517
518	for len(p.Ops) < maxOps {
519		slot, ok := d.intn(len(opTable))
520		if !ok {
521			break
522		}
523
524		op := Op{Kind: opTable[slot]}
525		switch op.Kind {
526		case OpDrawLine:
527			// A frame with no rows has nowhere to draw. Skip the op rather than
528			// end the program: an inline frame may collapse to nothing and then
529			// grow again, and truncating here made everything after a collapse
530			// unreachable, including the redraw that proves it recovered.
531			if curH <= 0 {
532				continue
533			}
534			y, ok := d.intn(curH)
535			if !ok {
536				return p
537			}
538			op.Y = y
539
540			// Build the line from whole clusters and stop before the right
541			// margin, so content cannot wrap onto the next row. Wrapping is a
542			// separate concern, and content spilling across rows would make
543			// failures ambiguous about which row was actually wrong.
544			var sb strings.Builder
545			used := 0
546			for {
547				idx, ok := d.intn(len(corpusAlphabet) + 1)
548				if !ok {
549					return p
550				}
551				// The extra index terminates the line, letting the fuzzer
552				// control length instead of always filling the row.
553				if idx == len(corpusAlphabet) {
554					break
555				}
556				g := corpusAlphabet[idx]
557				gw := ansi.StringWidth(g)
558				if used+gw > curW-1 {
559					break
560				}
561				sb.WriteString(g)
562				used += gw
563			}
564			op.Text = sb.String()
565
566		case OpMoveTo:
567			// Likewise, a frame with no columns has nowhere to move to.
568			if curW <= 0 {
569				continue
570			}
571			n, ok := d.intn(curW)
572			if !ok {
573				return p
574			}
575			op.N = n
576
577		case OpResize:
578			nw, ok := d.intrange(MinResizeW, MaxResizeW)
579			if !ok {
580				return p
581			}
582			// An inline frame is allowed to collapse to nothing. A view can
583			// vanish while the application keeps drawing, and a frame with no
584			// rows leaves the erase below it no row of its own to start from,
585			// which is the case most likely to reach above the frame.
586			minH := MinResizeH
587			if p.Inline {
588				minH = 0
589			}
590			nh, ok := d.intrange(minH, MaxResizeH)
591			if !ok {
592				return p
593			}
594			// An inline frame is never narrowed: the terminal rewraps the rows
595			// it holds and carries the cursor somewhere a relative move cannot
596			// find again, which is a property of the mode rather than a defect.
597			// Widening is safe and worth fuzzing.
598			if p.Inline {
599				nw = max(nw, curW)
600			}
601
602			op.W, op.H = nw, nh
603			curW, curH = nw, nh
604		}
605
606		p.Ops = append(p.Ops, op)
607	}
608
609	return p
610}