Commit 5ed0f4f4cc

5ed0f4f4ccff7a44978066378c5751899f47a0af

parent: dd23d1750e

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-24 14:47 UTC

control: termSafe drops carriage returns

Ref #254
internal/control/term.go +7 −2
@@ -48,7 +48,9 @@ const (
4848// termSafe replaces the bytes a terminal would act on — ESC, the C0
4949// controls but tab and newline, DEL, and the C1 controls — with U+FFFD,
5050// so user text cannot move the cursor, set the clipboard (OSC 52) or
51// clear the screen. Terminal output only: plain output is unchanged.
51// clear the screen. Carriage return is dropped rather than replaced:
52// web forms store CRLF line endings, and a lone CR would let text
53// overwrite its own line. Terminal output only: plain output is unchanged.
5254func termSafe(s string) string {
5355 unsafe := func(r rune) bool {
5456 return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f)
@@ -57,7 +59,10 @@ func termSafe(s string) string {
5759 return s
5860 }
5961 return strings.Map(func(r rune) rune {
60 if unsafe(r) {
62 switch {
63 case r == '\r':
64 return -1
65 case unsafe(r):
6166 return '\uFFFD'
6267 }
6368 return r
internal/control/termsafe_test.go +20 −1
@@ -15,7 +15,9 @@ func TestTermSafe(t *testing.T) {
1515 cases := map[string]string{
1616 "plain\ttext\n": "plain\ttext\n",
1717 "a\x1bb": "a�b",
18 "a\rb\x00c\x7fd": "a�b�c�d",
18 "a\x00c\x7fd": "a�c�d",
19 "a\r\nb\r\n": "a\nb\n",
20 "a\rb": "ab",
1921 "a\u0085b\u009bc": "a�b�c",
2022 "ümlaut": "ümlaut",
2123 }
@@ -68,3 +70,20 @@ func TestIssueShowControlBytes(t *testing.T) {
6870 t.Errorf("plain title changed:\n%q", out)
6971 }
7072}
73
74// A body written in a browser arrives with CRLF line endings.
75func TestIssueShowCRLFBody(t *testing.T) {
76 st, repo, uid := newQueueTestRepo(t)
77 if _, err := st.CreateIssue(repo.ID, uid, "t", "first line\r\nsecond line\r\n", "md"); err != nil {
78 t.Fatal(err)
79 }
80 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
81 c.Term = Term{Cols: 80}
82 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
83 t.Fatalf("exit %d: %s", code, errOut)
84 }
85 out := c.Stdout.(*bytes.Buffer).String()
86 if strings.ContainsAny(out, "\r�") || !strings.Contains(out, "first line second line") {
87 t.Errorf("CRLF body at a terminal:\n%q", out)
88 }
89}