From 0353e1cbf5af38f140ae0ba701d821ffa2da29f7 Mon Sep 17 00:00:00 2001 From: maful Date: Sun, 13 Sep 2026 03:06:14 +0700 Subject: [PATCH] fix(ui): sanitize terminal control sequences --- internal/ui/log_buffer.go | 1 + internal/ui/log_buffer_test.go | 2 +- internal/ui/model.go | 10 +- internal/ui/terminal_text.go | 147 ++++++++++++++++++++++++++++ internal/ui/terminal_text_test.go | 154 ++++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 internal/ui/terminal_text.go create mode 100644 internal/ui/terminal_text_test.go diff --git a/internal/ui/log_buffer.go b/internal/ui/log_buffer.go index f73ea03..4c4b3e7 100644 --- a/internal/ui/log_buffer.go +++ b/internal/ui/log_buffer.go @@ -50,6 +50,7 @@ func (b *logBuffer) clear() { } func (b *logBuffer) append(raw string) bool { + raw = sanitizeLogLine(raw) entry := logEntry{ sequence: b.nextSequence, raw: raw, diff --git a/internal/ui/log_buffer_test.go b/internal/ui/log_buffer_test.go index 06b3f00..840dc5d 100644 --- a/internal/ui/log_buffer_test.go +++ b/internal/ui/log_buffer_test.go @@ -53,7 +53,7 @@ func TestLogBufferMatchesCaseInsensitivelyWithoutANSI(t *testing.T) { buffer.setQuery("error") - if got, want := buffer.visibleLines(), []string{"\x1b[31mERROR\x1b[0m database unavailable"}; !slices.Equal(got, want) { + if got, want := buffer.visibleLines(), []string{"\x1b[31mERROR\x1b[0m database unavailable\x1b[0m"}; !slices.Equal(got, want) { t.Fatalf("visible lines = %q, want %q", got, want) } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 5e0d6b9..3bad50d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -106,6 +106,8 @@ func New(definitions []procfile.Process, source processSource, path, workingDire filterInput.CharLimit = 256 views := make([]processView, len(definitions)) for index, definition := range definitions { + definition.Name = sanitizeDisplayText(definition.Name) + definition.Command = sanitizeDisplayText(definition.Command) view := viewport.New(0, 0) view.MouseWheelDelta = 3 views[index] = processView{ @@ -122,10 +124,10 @@ func New(definitions []procfile.Process, source processSource, path, workingDire return Model{ processes: views, source: source, - path: path, - workingDirectory: abbreviateHomeDirectory(workingDirectory, homeDirectory), - branch: branch, - version: version, + path: sanitizeDisplayText(path), + workingDirectory: sanitizeDisplayText(abbreviateHomeDirectory(workingDirectory, homeDirectory)), + branch: sanitizeDisplayText(branch), + version: sanitizeDisplayText(version), startupSpinner: startupSpinner, filterInput: filterInput, filterProcess: -1, diff --git a/internal/ui/terminal_text.go b/internal/ui/terminal_text.go new file mode 100644 index 0000000..cf40105 --- /dev/null +++ b/internal/ui/terminal_text.go @@ -0,0 +1,147 @@ +package ui + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +const ( + escapeByte = '\x1b' + deleteByte = '\x7f' + csiByte = '\x9b' + dcsByte = '\x90' + oscByte = '\x9d' + sosByte = '\x98' + pmByte = '\x9e' + apcByte = '\x9f' + stByte = '\x9c' + belByte = '\x07' + + maxSGRSequenceBytes = 128 +) + +// sanitizeLogLine retains printable text, tabs, and conventional SGR styling. +// All other terminal controls are discarded before the line reaches a renderer. +func sanitizeLogLine(value string) string { + return sanitizeTerminalText(value, true, true) +} + +// sanitizeDisplayText produces plain, single-line text for UI labels. +func sanitizeDisplayText(value string) string { + return sanitizeTerminalText(value, false, false) +} + +func sanitizeTerminalText(value string, allowSGR, allowTab bool) string { + var output strings.Builder + output.Grow(len(value)) + retainedSGR := false + + for offset := 0; offset < len(value); { + current := value[offset] + switch { + case current == escapeByte: + end := consumeEscapeSequence(value, offset) + sequence := value[offset:end] + if allowSGR && validSGRSequence(sequence) { + output.WriteString(sequence) + retainedSGR = true + } + offset = end + case current == csiByte: + offset = consumeCSISequence(value, offset+1) + case current == dcsByte || current == sosByte || current == oscByte || current == pmByte || current == apcByte: + offset = consumeControlString(value, offset+1, current == oscByte) + case current < utf8.RuneSelf: + if current == '\t' && allowTab { + output.WriteByte(current) + } else if current >= ' ' && current != deleteByte { + output.WriteByte(current) + } + offset++ + default: + r, size := utf8.DecodeRuneInString(value[offset:]) + if r == utf8.RuneError && size == 1 { + output.WriteRune(utf8.RuneError) + offset++ + continue + } + if !unicode.IsControl(r) { + output.WriteString(value[offset : offset+size]) + } + offset += size + } + } + + if retainedSGR && !strings.HasSuffix(output.String(), "\x1b[0m") && !strings.HasSuffix(output.String(), "\x1b[m") { + output.WriteString("\x1b[0m") + } + return output.String() +} + +func consumeEscapeSequence(value string, start int) int { + if start+1 >= len(value) { + return len(value) + } + + switch value[start+1] { + case '[': + return consumeCSISequence(value, start+2) + case 'P', 'X', '^', '_': + return consumeControlString(value, start+2, false) + case ']': + return consumeControlString(value, start+2, true) + } + + offset := start + 1 + for offset < len(value) && value[offset] >= 0x20 && value[offset] <= 0x2f { + offset++ + } + if offset < len(value) && value[offset] >= 0x30 && value[offset] <= 0x7e { + return offset + 1 + } + return max(start+1, offset) +} + +func consumeCSISequence(value string, offset int) int { + for offset < len(value) { + current := value[offset] + offset++ + if current >= 0x40 && current <= 0x7e { + return offset + } + } + return len(value) +} + +func consumeControlString(value string, offset int, osc bool) int { + for offset < len(value) { + switch value[offset] { + case belByte: + if osc { + return offset + 1 + } + case stByte: + return offset + 1 + case escapeByte: + if offset+1 < len(value) && value[offset+1] == '\\' { + return offset + 2 + } + } + offset++ + } + return len(value) +} + +func validSGRSequence(sequence string) bool { + if len(sequence) < 3 || len(sequence) > maxSGRSequenceBytes || + !strings.HasPrefix(sequence, "\x1b[") || sequence[len(sequence)-1] != 'm' { + return false + } + for _, current := range sequence[2 : len(sequence)-1] { + if (current < '0' || current > '9') && current != ';' && current != ':' { + return false + } + } + return true +} diff --git a/internal/ui/terminal_text_test.go b/internal/ui/terminal_text_test.go new file mode 100644 index 0000000..4c8e1ae --- /dev/null +++ b/internal/ui/terminal_text_test.go @@ -0,0 +1,154 @@ +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + + "github.com/maful/inline/internal/process" + "github.com/maful/inline/internal/procfile" +) + +func TestSanitizeLogLinePreservesTextAndSGR(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "printable Unicode and tab", input: "ready\tcafé 👩‍💻", want: "ready\tcafé 👩‍💻"}, + {name: "basic color and reset", input: "\x1b[1;31merror\x1b[0m", want: "\x1b[1;31merror\x1b[0m"}, + {name: "256 color", input: "\x1b[38;5;214mwarning", want: "\x1b[38;5;214mwarning\x1b[0m"}, + {name: "true color with colon syntax", input: "\x1b[38:2::12:34:56mvalue", want: "\x1b[38:2::12:34:56mvalue\x1b[0m"}, + {name: "invalid UTF-8", input: string([]byte{'a', 0xff, 'b'}), want: "a�b"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := sanitizeLogLine(test.input); got != test.want { + t.Fatalf("sanitizeLogLine() = %q, want %q", got, test.want) + } + }) + } +} + +func TestSanitizeLogLineDiscardsTerminalControls(t *testing.T) { + tests := []struct { + name string + control string + }{ + {name: "OSC 52 with BEL", control: "\x1b]52;c;dGVzdA==\x07"}, + {name: "window title with ST", control: "\x1b]0;inline-test\x1b\\"}, + {name: "hyperlink", control: "\x1b]8;;https://example.com\x1b\\"}, + {name: "screen erase", control: "\x1b[2J"}, + {name: "cursor movement", control: "\x1b[4A"}, + {name: "terminal mode", control: "\x1b[?25l"}, + {name: "device control string", control: "\x1bP1;2|payload\x1b\\"}, + {name: "application program command", control: "\x1b_payload\x1b\\"}, + {name: "privacy message", control: "\x1b^payload\x1b\\"}, + {name: "start of string", control: "\x1bXpayload\x1b\\"}, + {name: "C0 controls", control: "\a\b\r\n"}, + {name: "C1 OSC", control: string([]byte{oscByte}) + "0;title" + string([]byte{stByte})}, + {name: "C1 CSI", control: string([]byte{csiByte}) + "2J"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := "before" + test.control + "after" + if got, want := sanitizeLogLine(input), "beforeafter"; got != want { + t.Fatalf("sanitizeLogLine() = %q, want %q", got, want) + } + }) + } +} + +func TestSanitizeLogLineDiscardsIncompleteSequences(t *testing.T) { + for _, input := range []string{ + "prefix\x1b", + "prefix\x1b[31", + "prefix\x1b]52;c;payload", + "prefix\x1bPpayload", + } { + if got, want := sanitizeLogLine(input), "prefix"; got != want { + t.Errorf("sanitizeLogLine(%q) = %q, want %q", input, got, want) + } + } +} + +func TestSanitizeDisplayTextRemovesStylesAndWhitespaceControls(t *testing.T) { + input := "web\t\x1b[31mred\x1b[0m\n" + if got, want := sanitizeDisplayText(input), "webred"; got != want { + t.Fatalf("sanitizeDisplayText() = %q, want %q", got, want) + } +} + +func TestValidSGRSequenceRejectsOtherCSIAndOversizedParameters(t *testing.T) { + if validSGRSequence("\x1b[2J") || validSGRSequence("\x1b[?25m") { + t.Fatal("accepted a non-SGR control sequence") + } + if validSGRSequence("\x1b[" + strings.Repeat("1", maxSGRSequenceBytes) + "m") { + t.Fatal("accepted an oversized SGR sequence") + } +} + +func TestModelSanitizesProcessOutputBeforeStorageAndRendering(t *testing.T) { + payloads := map[string]string{ + "OSC 52": "\x1b]52;c;dGVzdA==\x07", + "screen erase": "\x1b[2J", + "window title": "\x1b]0;inline-test\x07", + } + + for name, payload := range payloads { + t.Run(name, func(t *testing.T) { + model := newTestModel() + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updated.(Model) + updated, _ = model.Update(process.Event{Index: 0, Line: "before" + payload + "after"}) + model = updated.(Model) + + if got, want := model.processes[0].logs.visibleLines(), []string{"beforeafter"}; len(got) != 1 || got[0] != want[0] { + t.Fatalf("stored logs = %q, want %q", got, want) + } + if strings.Contains(model.View(), payload) { + t.Fatal("control sequence survived the unfiltered view") + } + + model.processes[0].logs.setQuery("beforeafter") + model.processes[0].dirty = true + model.refreshSelected() + if strings.Contains(model.View(), payload) { + t.Fatal("control sequence survived the filtered view") + } + }) + } +} + +func TestModelSanitizesDisplayMetadataWithoutChangingSourceDefinitions(t *testing.T) { + payload := "\x1b]0;inline-test\x07" + definitions := []procfile.Process{{Name: "web" + payload, Command: "echo" + payload + " done"}} + original := definitions[0] + model := New( + definitions, + &fakeSource{events: make(chan process.Event)}, + "Procfile"+payload, + "/tmp/project"+payload, + "main"+payload, + "v1"+payload, + ) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + view := updated.(Model).View() + + if strings.Contains(view, payload) { + t.Fatal("control sequence survived metadata rendering") + } + plain := ansi.Strip(view) + for _, want := range []string{"web", "$ echo done", "Procfile", "/tmp/project", "main", "v1"} { + if !strings.Contains(plain, want) { + t.Errorf("rendered metadata does not contain %q", want) + } + } + if definitions[0] != original { + t.Fatalf("source definition changed from %#v to %#v", original, definitions[0]) + } +}