From a8805d9be1037f0ba8ffe3d3c2372978a5965032 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Wed, 21 Oct 2026 02:44:13 -0400 Subject: [PATCH] v0.4.3 prevent input stall on long or malformed sequence --- input.go | 151 +++++++++++++++++++++++++++-------------------- terminal_test.go | 87 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 65 deletions(-) diff --git a/input.go b/input.go index caa87e9..60770c6 100644 --- a/input.go +++ b/input.go @@ -57,6 +57,9 @@ type inputReader struct { paste []byte pasteSeen time.Time + // An over-long CSI sequence is being dropped up to its final byte + dropCSI bool + mu sync.Mutex running bool } @@ -186,6 +189,19 @@ func (r *inputReader) parseInput(data []byte) int { default: } + if r.dropCSI { + for i < n && data[i] >= 0x20 && data[i] <= 0x3f { + i++ + } + if i < n { + r.dropCSI = false + if data[i] >= 0x40 && data[i] <= 0x7e { + i++ + } + } + continue + } + if r.pasting { end := bytes.Index(data[i:], pasteEnd) if end < 0 { @@ -321,7 +337,7 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) { return 0, Event{} case bytes.HasPrefix(rest, pasteStart): return 1, Event{Type: EventKey, Key: KeyEscape} - case rest[1] == '[' || rest[1] == 'O': + case rest[1] == 'O', rest[1] == '[' && csiLen(rest) != csiTyped: n, ev := r.parseEscape(rest) if n == 0 { return 0, Event{} @@ -351,55 +367,79 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) { return 2, Event{Type: EventKey, Key: KeyRune, Rune: rune(data[1]), Modifiers: ModAlt} } - return 0, Event{} + // Alt+Backspace + if data[1] == 0x7f { + return 2, Event{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt} + } + + // Alt+multibyte character; a byte no character starts with leaves Escape + size := utf8SeqLen(data[1]) + if size == 0 { + return 1, Event{Type: EventKey, Key: KeyEscape} + } + if len(data) < 1+size { + return 0, Event{} + } + rn, n := decodeRune(data[1:]) + return 1 + n, Event{Type: EventKey, Key: KeyRune, Rune: rn, Modifiers: ModAlt} +} + +// csiMaxLen bounds a CSI sequence a terminal sends, with ESC [ and its final +// byte; a longer one is dropped up to its final byte +const csiMaxLen = 256 + +// csiLen results other than a length: more may be arriving, a byte no +// sequence holds came (ESC [ was typed, as Alt+[), or no final byte came +// within csiMaxLen +const ( + csiPartial = 0 + csiTyped = -1 + csiOverlong = -2 +) + +// csiLen returns the length of the CSI sequence data starts with, or why it +// has none yet. Parameter and intermediate bytes run to a final byte as in +// ECMA-48, but for the Linux console's F1-F5, ESC [ [ A to E. +func csiLen(data []byte) int { + i := 2 + if len(data) > 2 && data[2] == '[' { + i = 3 + } + for ; i < len(data); i++ { + if i == csiMaxLen { + return csiOverlong + } + switch b := data[i]; { + case b >= 0x40 && b <= 0x7e: + return i + 1 + case b < 0x20 || b > 0x3f: + return csiTyped + } + } + return csiPartial } // parseCSI parses CSI sequence without allocation func (r *inputReader) parseCSI(data []byte) (int, Event) { - if len(data) < 3 { + n := csiLen(data) + switch { + case n == csiPartial: return 0, Event{} + case n == csiTyped: + return 2, Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt} + case n == csiOverlong: + r.dropCSI = true + return csiMaxLen, Event{Type: EventKey, Key: KeyNone} + case data[2] == '<' && (data[n-1] == 'M' || data[n-1] == 'm'): + return n, parseSGRMouse(data[3:n-1], data[n-1]) } - // SGR mouse: ESC [ < Btn ; X ; Y M/m - if data[2] == '<' { - return r.parseSGRMouse(data) - } - - end := 2 - maxScan := len(data) - if maxScan > 16 { - maxScan = 16 - } - - for end < maxScan { - b := data[end] - if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' { - end++ - break - } - if b < 0x20 || b > 0x7e { - return 0, Event{} - } - end++ - } - - // Check if we found a terminator or ran out of data - if end <= 2 || end > maxScan { - return 0, Event{} // Incomplete - } - - // Check last byte is valid terminator - lastByte := data[end-1] - if !((lastByte >= 'A' && lastByte <= 'Z') || (lastByte >= 'a' && lastByte <= 'z') || lastByte == '~') { - return 0, Event{} // Incomplete, no terminator found - } - - if key, mod, ok := lookupCSI(data[2:end]); ok { - return end, Event{Type: EventKey, Key: key, Modifiers: mod} + if key, mod, ok := lookupCSI(data[2:n]); ok { + return n, Event{Type: EventKey, Key: key, Modifiers: mod} } // Unknown but valid CSI syntax - consume and return KeyNone - return end, Event{Type: EventKey, Key: KeyNone} + return n, Event{Type: EventKey, Key: KeyNone} } // parseSS3 parses SS3 sequence without allocation, returns length even for unknown sequences @@ -483,31 +523,12 @@ func (r *inputReader) parseControl(b byte) Event { return Event{Type: EventKey, Key: KeyNone} } -// parseSGRMouse parses mouse SGR sequences -func (r *inputReader) parseSGRMouse(data []byte) (int, Event) { - // Format: ESC [ < Btn ; X ; Y M/m - // Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes - if len(data) < 10 { - return 0, Event{} - } - - // Find terminator M or m - end := 3 - for end < len(data) && end < 32 { - if data[end] == 'M' || data[end] == 'm' { - break - } - end++ - } - if end >= len(data) || (data[end] != 'M' && data[end] != 'm') { - return 0, Event{} - } - - // Parse: Btn;X;Y - params := data[3:end] +// parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from +// the parameters and final byte; a malformed one is dropped as KeyNone +func parseSGRMouse(params []byte, final byte) Event { btn, x, y, ok := parseSGRParams(params) if !ok { - return 0, Event{} + return Event{Type: EventKey, Key: KeyNone} } ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed @@ -541,7 +562,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) { ev.MouseBtn = MouseBtnNone // Release with no specific button } - if data[end] == 'M' { + if final == 'M' { if isMotion { if ev.MouseBtn != MouseBtnNone { ev.MouseAction = MouseActionDrag @@ -567,7 +588,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) { ev.Modifiers |= ModCtrl } - return end + 1, ev + return ev } // parseSGRParams extracts btn, x, y from "Btn;X;Y" format diff --git a/terminal_test.go b/terminal_test.go index 6e83285..6686f3b 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -3,6 +3,7 @@ package terminal import ( "bytes" "cmp" + "slices" "strings" "sync" "testing" @@ -152,6 +153,92 @@ func TestDoubleEscape(t *testing.T) { func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} } +// parsed is what a reader sends for input arriving in chunks without pauses, +// and how many bytes it still holds back for the rest of a sequence +func parsed(chunks ...string) (events []Event, held int) { + r := &inputReader{eventCh: make(chan Event, 4096)} + r.pasteMode.Store(true) + var buf []byte + for _, c := range chunks { + buf = append(buf, c...) + buf = buf[r.parseInput(buf):] + } + close(r.eventCh) + for ev := range r.eventCh { + events = append(events, ev) + } + return events, len(buf) +} + +// Every sequence the key tables hold reads as its key, and the key after it +// as itself +func TestKnownSequencesKeepTheirKeys(t *testing.T) { + for _, s := range csiSequences { + want := []Event{{Type: EventKey, Key: s.key, Modifiers: s.mod}, key('x')} + if got, _ := parsed("\x1b[" + s.seq + "x"); !slices.Equal(got, want) { + t.Errorf("ESC [ %s: got %+v, want %+v", s.seq, got, want) + } + } +} + +// No input stops the reader: a reply of any length, Alt with Backspace or a +// character past ASCII, a malformed mouse report, or ESC [ before a byte no +// sequence holds (typed Alt+[) leave the keys after them read. An unknown or +// over-long sequence is dropped up to its final byte, by ECMA-48. +func TestNoInputStopsTheReader(t *testing.T) { + altBracket := Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt} + enter := Event{Type: EventKey, Key: KeyEnter} + for _, c := range []struct { + name string + chunks []string + want []Event + }{ + {"long reply", []string{"\x1b[?65;1;9;15;18;21;22cx"}, []Event{key('x')}}, + {"split reply", []string{"\x1b[?65;1;9;", "15;18;21;22", "cx"}, []Event{key('x')}}, + {"longest reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen-3) + "zx"}, []Event{key('x')}}, + {"over-long reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + ";2", "3zx"}, []Event{key('x')}}, + {"over-long cut", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + "\rx"}, []Event{enter, key('x')}}, + {"unknown final", []string{"\x1b[2^x"}, []Event{key('x')}}, + {"alt+backspace", []string{"\x1b\x7fx"}, []Event{{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}, key('x')}}, + {"alt+ö", []string{"\x1b\xc3", "\xb6x"}, []Event{{Type: EventKey, Key: KeyRune, Rune: 'ö', Modifiers: ModAlt}, key('x')}}, + {"stray byte", []string{"\x1b\x80x"}, []Event{{Type: EventKey, Key: KeyEscape}, key('x')}}, + {"alt+[ enter", []string{"\x1b[\rx"}, []Event{altBracket, enter, key('x')}}, + {"alt+[ 1 é", []string{"\x1b[1éx"}, []Event{altBracket, key('1'), key('é'), key('x')}}, + {"esc alt+[ enter", []string{"\x1b\x1b[\rx"}, []Event{{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}, key('['), enter, key('x')}}, + {"bad mouse", []string{"\x1b[<0;1;1;1Mx"}, []Event{key('x')}}, + {"mouse", []string{"\x1b[<0;5;3M\x1b[<0;5;3m"}, []Event{ + {Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionPress}, + {Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionRelease}}}, + } { + if got, held := parsed(c.chunks...); !slices.Equal(got, c.want) || held != 0 { + t.Errorf("%s: got %+v holding %d, want %+v", c.name, got, held, c.want) + } + } +} + +// Whatever bytes arrive, however reads split them, the reader sends the same +// events, holds back no more than ESC and the longest sequence, and holds +// nothing once four DEL, which end any sequence or character, and a paste +// end follow +func FuzzInputNeverStalls(f *testing.F) { + for _, s := range []string{"\x1b[?65;1;9;15;18;21;22cx", "\x1b\x7fx", "\x1b\xc3\xb6", "\x1b[\rx", "\x1b[<0;1;1;1Mx", "\x1b\x1b[A", "a\x1b[200~\x1b[A\x1b[201~b"} { + f.Add(s, uint(len(s)/2)) + } + f.Fuzz(func(t *testing.T, in string, cut uint) { + cut %= uint(len(in)) + 1 + whole, held := parsed(in) + if split, _ := parsed(in[:cut], in[cut:]); !slices.Equal(whole, split) { + t.Fatalf("%q: whole %+v, split at %d %+v", in, whole, cut, split) + } + if held > csiMaxLen+1 { + t.Fatalf("%q: holds %d bytes", in, held) + } + if _, held := parsed(in, "\x7f\x7f\x7f\x7f\x1b[201~"); held != 0 { + t.Fatalf("%q: stalled holding %d bytes", in, held) + } + }) +} + // input is what a reader is given, in chunks, and the events it must send type input struct { off bool