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 ( |
| 48 | 48 | // termSafe replaces the bytes a terminal would act on — ESC, the C0 |
| 49 | 49 | // controls but tab and newline, DEL, and the C1 controls — with U+FFFD, |
| 50 | 50 | // 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. |
| 52 | 54 | func termSafe(s string) string { |
| 53 | 55 | unsafe := func(r rune) bool { |
| 54 | 56 | return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f) |
| @@ -57,7 +59,10 @@ func termSafe(s string) string { |
| 57 | 59 | return s |
| 58 | 60 | } |
| 59 | 61 | return strings.Map(func(r rune) rune { |
| 60 | | if unsafe(r) { |
| 62 | switch { |
| 63 | case r == '\r': |
| 64 | return -1 |
| 65 | case unsafe(r): |
| 61 | 66 | return '\uFFFD' |
| 62 | 67 | } |
| 63 | 68 | return r |
internal/control/termsafe_test.go
+20 −1
| @@ -15,7 +15,9 @@ func TestTermSafe(t *testing.T) { |
| 15 | 15 | cases := map[string]string{ |
| 16 | 16 | "plain\ttext\n": "plain\ttext\n", |
| 17 | 17 | "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", |
| 19 | 21 | "a\u0085b\u009bc": "a�b�c", |
| 20 | 22 | "ümlaut": "ümlaut", |
| 21 | 23 | } |
| @@ -68,3 +70,20 @@ func TestIssueShowControlBytes(t *testing.T) { |
| 68 | 70 | t.Errorf("plain title changed:\n%q", out) |
| 69 | 71 | } |
| 70 | 72 | } |
| 73 | |
| 74 | // A body written in a browser arrives with CRLF line endings. |
| 75 | func 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 | } |