1package tui
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/provider"
9 "github.com/mattn/go-runewidth"
10)
11
12// FuzzViewRowsStayTerminalSafe feeds untrusted text through every entry
13// point that reaches the chat view and checks the rendered rows. Any path
14// that forgets to sanitize shows up as a control byte or a misaligned
15// box edge.
16func FuzzViewRowsStayTerminalSafe(f *testing.F) {
17 for _, seed := range []string{
18 "plain text",
19 "old=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {",
20 "progress 10%\rprogress 100%\r\n",
21 "\x1b[2J\x1b[H\x1b]0;title\x07\x1bPdcs\x1b\\bell\a back\b",
22 "--- a.go\n+++ a.go\n@@ -1,2 +1,2 @@\n-\told\n+\tnew\n \tctx",
23 "```go\nfunc main() {\n\tprintln(1)\n}\n```",
24 "wide 日本語\tcol\x7f\u0085\xff",
25 "\u0604", // invisible format character
26 "00\n\r0\t", // CR inside a numbered-file gutter
27 "&0&0\u05a30", // zero-width mark in a truncated tool header
28 } {
29 f.Add(seed)
30 }
31 f.Fuzz(func(t *testing.T, text string) {
32 for _, width := range []int{40, 100} {
33 assertTerminalSafeRows(t, transcriptView(text).Build(width), width)
34 for _, v := range liveViews(text) {
35 assertTerminalSafeRows(t, v.BuildLive(width), width)
36 }
37 }
38 })
39}
40
41func transcriptView(text string) *View {
42 callArgs := func(k, v string) json.RawMessage {
43 b, _ := json.Marshal(map[string]string{k: v})
44 return b
45 }
46 msgs := []provider.Message{
47 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}},
48 {Role: provider.RoleAssistant, Content: []provider.Content{
49 provider.TextBlock{Text: text},
50 provider.ToolCallBlock{ID: "t1", Name: "bash", Arguments: callArgs("command", text)},
51 provider.ToolCallBlock{ID: "t2", Name: "read", Arguments: callArgs("path", "a.go")},
52 provider.ToolCallBlock{ID: "t3", Name: "custom", Arguments: callArgs("q", text)},
53 }},
54 }
55 for id, result := range map[string]string{
56 "t1": "$ " + text + "\n" + text + "\n[exit 0]",
57 "t2": text,
58 "t3": text,
59 } {
60 msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
61 provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: result}}},
62 }})
63 }
64 return &View{Theme: Dark, ExpandAll: true, Messages: msgs, Err: text}
65}
66
67func liveViews(text string) []*View {
68 var out []*View
69 for _, tc := range []struct{ name, field string }{
70 {"bash", "command"},
71 {"write", "content"},
72 } {
73 args, _ := json.Marshal(map[string]string{tc.field: text, "path": "a.go"})
74 out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{
75 ID: "live", Name: tc.name, Args: ShortArgs(tc.name, args), RawJSONBuf: string(args), LivePath: "a.go",
76 }}})
77 }
78 edit, _ := json.Marshal(map[string]any{"path": "a.go", "edits": []map[string]string{{"oldText": "x", "newText": text}}})
79 out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{
80 ID: "edit", Name: "edit", RawJSONBuf: string(edit), LivePath: "a.go",
81 }}})
82 out = append(out, &View{Theme: Dark, StreamingActive: true, Streaming: "x" + text, ToolCalls: []ToolCallView{{
83 ID: "res", Name: "bash", Args: "cmd", Result: text, Done: true,
84 }}})
85 return out
86}
87
88// assertTerminalSafeRows checks the invariant the renderer relies on:
89// once SGR color codes are removed, every row contains only printable
90// text, fits the terminal, and box side rows reach the right edge.
91func assertTerminalSafeRows(t *testing.T, rows []string, width int) {
92 t.Helper()
93 for i, row := range rows {
94 plain := stripSGR(row)
95 for _, r := range plain {
96 if r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) {
97 t.Fatalf("row %d contains control %U: %q", i, r, row)
98 }
99 }
100 w := runewidth.StringWidth(plain)
101 if w > width {
102 t.Fatalf("row %d is %d cols wide, terminal is %d: %q", i, w, width, plain)
103 }
104 trimmed := strings.TrimSpace(plain)
105 if strings.HasPrefix(trimmed, "│") && w != width {
106 t.Fatalf("box row %d is %d cols wide, want %d: %q", i, w, width, plain)
107 }
108 }
109}
110
111// stripSGR removes only CSI ... m color sequences. Any other escape is
112// left in place so the control check above flags it.
113func stripSGR(s string) string {
114 var b strings.Builder
115 for i := 0; i < len(s); {
116 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
117 j := i + 2
118 for j < len(s) && s[j] >= 0x30 && s[j] <= 0x3f {
119 j++
120 }
121 if j < len(s) && s[j] == 'm' {
122 i = j + 1
123 continue
124 }
125 }
126 b.WriteByte(s[i])
127 i++
128 }
129 return b.String()
130}