Commit 037e004e54
Verified · cmc
internal/control/table.go +1
| @@ -61,6 +61,7 @@ func (t *table) row(cs ...cell) { | ||
| 61 | 61 | } |
| 62 | 62 | now := termNow() |
| 63 | 63 | for i := range cs { |
| 64 | cs[i].s = termSafe(cs[i].s) | |
| 64 | 65 | if cs[i].kind == kindAge { |
| 65 | 66 | cs[i].s = relAge(cs[i].s, now) |
| 66 | 67 | } |
internal/control/term.go +27
| @@ -46,6 +46,33 @@ const ( | ||
| 46 | 46 | sgrMagenta = "\x1b[35m" |
| 47 | 47 | ) |
| 48 | 48 | |
| 49 | // termSafe replaces the bytes a terminal would act on — ESC, the C0 | |
| 50 | // controls but tab and newline, DEL, and the C1 controls — with U+FFFD, | |
| 51 | // so user text cannot move the cursor, set the clipboard (OSC 52) or | |
| 52 | // clear the screen. Terminal output only: plain output is unchanged. | |
| 53 | func termSafe(s string) string { | |
| 54 | unsafe := func(r rune) bool { | |
| 55 | return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f) | |
| 56 | } | |
| 57 | if strings.IndexFunc(s, unsafe) < 0 { | |
| 58 | return s | |
| 59 | } | |
| 60 | return strings.Map(func(r rune) rune { | |
| 61 | if unsafe(r) { | |
| 62 | return '\uFFFD' | |
| 63 | } | |
| 64 | return r | |
| 65 | }, s) | |
| 66 | } | |
| 67 | ||
| 68 | // safe is termSafe at a terminal and s unchanged in plain output. | |
| 69 | func (t Term) safe(s string) string { | |
| 70 | if t.Cols == 0 { | |
| 71 | return s | |
| 72 | } | |
| 73 | return termSafe(s) | |
| 74 | } | |
| 75 | ||
| 49 | 76 | // paint wraps s in an SGR sequence when colour is on. |
| 50 | 77 | func (t Term) paint(sgr, s string) string { |
| 51 | 78 | if !t.Color || sgr == "" || s == "" { |
internal/control/termsafe_test.go added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | const evil = "x\x1b]52;c;ZXZpbA==\x07y\x1b[2Jz" | |
| 13 | ||
| 14 | func TestTermSafe(t *testing.T) { | |
| 15 | cases := map[string]string{ | |
| 16 | "plain\ttext\n": "plain\ttext\n", | |
| 17 | "a\x1bb": "a�b", | |
| 18 | "a\rb\x00c\x7fd": "a�b�c�d", | |
| 19 | "a\u0085b\u009bc": "a�b�c", | |
| 20 | "ümlaut": "ümlaut", | |
| 21 | } | |
| 22 | for in, want := range cases { | |
| 23 | if got := termSafe(in); got != want { | |
| 24 | t.Errorf("termSafe(%q) = %q, want %q", in, got, want) | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | func noControls(t *testing.T, what, out string) { | |
| 30 | t.Helper() | |
| 31 | s := stripSGR(out) | |
| 32 | if strings.ContainsAny(s, "\x1b\x07") { | |
| 33 | t.Errorf("%s: control bytes reach the terminal: %q", what, s) | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | func TestTableRowControlBytes(t *testing.T) { | |
| 38 | var plain, term bytes.Buffer | |
| 39 | for _, c := range []struct { | |
| 40 | ctx *Ctx | |
| 41 | w *bytes.Buffer | |
| 42 | }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80, Color: true}}, &term}} { | |
| 43 | tb := c.ctx.table(c.w, "#", "STATE", "TITLE") | |
| 44 | tb.row(cRef("#1"), cState("open"), cFlex(evil)) | |
| 45 | tb.flush() | |
| 46 | } | |
| 47 | noControls(t, "table", term.String()) | |
| 48 | if want := "#1\topen\t" + evil + "\n"; plain.String() != want { | |
| 49 | t.Errorf("plain changed: %q", plain.String()) | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | func TestIssueShowControlBytes(t *testing.T) { | |
| 54 | show := func(term Term) string { | |
| 55 | st, repo, uid := newQueueTestRepo(t) | |
| 56 | if _, err := st.CreateIssue(repo.ID, uid, evil, "body "+evil, "md"); err != nil { | |
| 57 | t.Fatal(err) | |
| 58 | } | |
| 59 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | |
| 60 | c.Term = term | |
| 61 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { | |
| 62 | t.Fatalf("exit %d: %s", code, errOut) | |
| 63 | } | |
| 64 | return c.Stdout.(*bytes.Buffer).String() | |
| 65 | } | |
| 66 | noControls(t, "issue show", show(Term{Cols: 80, Color: true})) | |
| 67 | if out := show(Term{}); !strings.Contains(out, "#1 "+evil+" open\n") { | |
| 68 | t.Errorf("plain title changed:\n%q", out) | |
| 69 | } | |
| 70 | } | |
internal/control/view.go +7 −4
| @@ -70,6 +70,7 @@ func (v *view) section(label string) { | ||
| 70 | 70 | func (v *view) title(ref, title, state string) { |
| 71 | 71 | v.sep() |
| 72 | 72 | t := v.c.Term |
| 73 | ref, title, state = t.safe(ref), t.safe(title), t.safe(state) | |
| 73 | 74 | if t.Cols == 0 { |
| 74 | 75 | switch { |
| 75 | 76 | case title == "" && state == "": |
| @@ -134,7 +135,7 @@ func (v *view) fields(kv ...string) { | ||
| 134 | 135 | } |
| 135 | 136 | v.sep() |
| 136 | 137 | for i := 0; i+1 < len(kv); i += 2 { |
| 137 | key, val := kv[i], kv[i+1] | |
| 138 | key, val := v.c.Term.safe(kv[i]), v.c.Term.safe(kv[i+1]) | |
| 138 | 139 | if val == "" { |
| 139 | 140 | continue |
| 140 | 141 | } |
| @@ -159,6 +160,7 @@ func (v *view) body(src, format string) { | ||
| 159 | 160 | return |
| 160 | 161 | } |
| 161 | 162 | v.sep() |
| 163 | src = v.c.Term.safe(src) | |
| 162 | 164 | for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") { |
| 163 | 165 | if line == "" { |
| 164 | 166 | io.WriteString(v.w, "\n") |
| @@ -171,8 +173,8 @@ func (v *view) body(src, format string) { | ||
| 171 | 173 | // event is one line for a system comment: its text without link |
| 172 | 174 | // targets, the time at the right edge at a terminal. |
| 173 | 175 | func (v *view) event(text, format, ts string) { |
| 174 | line := "· " + termtext.Inline(text, format) | |
| 175 | when := v.c.when(ts) | |
| 176 | line := "· " + termtext.Inline(v.c.Term.safe(text), format) | |
| 177 | when := v.c.Term.safe(v.c.when(ts)) | |
| 176 | 178 | if cols := v.c.Term.Cols; cols > 0 { |
| 177 | 179 | room := cols - 2 - 2 - cells(when) |
| 178 | 180 | line = pad(clip(line, room), room) |
| @@ -182,7 +184,8 @@ func (v *view) event(text, format, ts string) { | ||
| 182 | 184 | } |
| 183 | 185 | |
| 184 | 186 | func (v *view) comment(author, ts, body, format string) { |
| 185 | when := v.c.when(ts) | |
| 187 | when := v.c.Term.safe(v.c.when(ts)) | |
| 188 | author = v.c.Term.safe(author) | |
| 186 | 189 | cols := v.c.Term.Cols |
| 187 | 190 | if cols > 0 { |
| 188 | 191 | suffix := ", " + when + " " |
internal/termtext/termtext.go +2
| @@ -20,6 +20,8 @@ type Options struct { | ||
| 20 | 20 | Base string |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | // Render lays out markdown or org for a terminal. It passes control | |
| 24 | // bytes in src through: callers sanitise src first (control.termSafe). | |
| 23 | 25 | func Render(src, format string, o Options) string { |
| 24 | 26 | if format == "org" { |
| 25 | 27 | return Org(src, o) |