detached 3959425

fix(tui): bound clipboard image reads

commit
3959425d5083c375d8b81d94737a90e026286584
author
brice · · 2026-08-04 09:40 UTC
parent
3346322
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +179 -31
M README.md +1 -1
@@ -761,7 +761,7 @@ A model installed through Ollama is kept in Ollama's internal storage and is not
761761
762762 ## Clipboard images
763763
764-In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available.
764+In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback.
765765
766766 ## Inline images
767767
@@ -761,7 +761,7 @@ A model installed through Ollama is kept in Ollama's internal storage and is not
761 761
762 ## Clipboard images762 ## Clipboard images
763 763
764-In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available.764+In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback.
765 765
766 ## Inline images766 ## Inline images
767 767
M packages/agent/modes/interactive.go +3 -3
@@ -2474,7 +2474,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24742474 i.invalidate()
24752475 return false
24762476 case tui.KeyPasteClipboard:
2477- i.pasteClipboard()
2477+ i.pasteClipboard(ctx)
24782478 return false
24792479 case tui.KeyCtrlO:
24802480 i.toggleToolExpansion()
@@ -2766,8 +2766,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27662766 return false
27672767 }
27682768
2769-func (i *Interactive) pasteClipboard() {
2770- image, ok, err := tui.ReadClipboardImage()
2769+func (i *Interactive) pasteClipboard(ctx context.Context) {
2770+ image, ok, err := tui.ReadClipboardImage(ctx)
27712771 if err != nil {
27722772 i.mu.Lock()
27732773 i.statusErr = "clipboard paste failed: " + err.Error()
@@ -2474,7 +2474,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2474 i.invalidate()2474 i.invalidate()
2475 return false2475 return false
2476 case tui.KeyPasteClipboard:2476 case tui.KeyPasteClipboard:
2477- i.pasteClipboard()2477+ i.pasteClipboard(ctx)
2478 return false2478 return false
2479 case tui.KeyCtrlO:2479 case tui.KeyCtrlO:
2480 i.toggleToolExpansion()2480 i.toggleToolExpansion()
@@ -2766,8 +2766,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2766 return false2766 return false
2767 }2767 }
2768 2768
2769-func (i *Interactive) pasteClipboard() {2769+func (i *Interactive) pasteClipboard(ctx context.Context) {
2770- image, ok, err := tui.ReadClipboardImage()2770+ image, ok, err := tui.ReadClipboardImage(ctx)
2771 if err != nil {2771 if err != nil {
2772 i.mu.Lock()2772 i.mu.Lock()
2773 i.statusErr = "clipboard paste failed: " + err.Error()2773 i.statusErr = "clipboard paste failed: " + err.Error()
M packages/tui/clipboard.go +12 -0
@@ -1,5 +1,17 @@
11 package tui
22
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+const (
9+ clipboardImageReadTimeout = 5 * time.Second
10+ maxClipboardImageBytes = 32 << 20
11+)
12+
13+var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
14+
315 // ClipboardImage is an image read from the system clipboard.
416 type ClipboardImage struct {
517 MimeType string
@@ -1,5 +1,17 @@
1 package tui1 package tui
2 2
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+const (
9+ clipboardImageReadTimeout = 5 * time.Second
10+ maxClipboardImageBytes = 32 << 20
11+)
12+
13+var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
14+
3 // ClipboardImage is an image read from the system clipboard.15 // ClipboardImage is an image read from the system clipboard.
4 type ClipboardImage struct {16 type ClipboardImage struct {
5 MimeType string17 MimeType string
M packages/tui/clipboard_darwin.go +55 -7
@@ -3,10 +3,12 @@
33 package tui
44
55 import (
6+ "context"
67 "crypto/rand"
78 "encoding/hex"
89 "fmt"
910 "image/png"
11+ "io"
1012 "os"
1113 "os/exec"
1214 "path/filepath"
@@ -47,18 +49,31 @@ on run argv
4749 end run
4850 `
4951
50-func ReadClipboardImage() (ClipboardImage, bool, error) {
51- _, data, ok, err := ReadClipboardImagePNG()
52+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
53+ if ctx == nil {
54+ ctx = context.Background()
55+ }
56+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
57+ defer cancel()
58+
59+ path, data, ok, err := readClipboardImagePNG(ctx)
5260 if err != nil {
5361 return ClipboardImage{}, false, err
5462 }
5563 if !ok {
5664 return ClipboardImage{}, false, nil
5765 }
66+ if err := removeClipboardImageFile(path); err != nil {
67+ return ClipboardImage{}, false, err
68+ }
5869 return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
5970 }
6071
6172 func ReadClipboardImagePNG() (string, []byte, bool, error) {
73+ return readClipboardImagePNG(context.Background())
74+}
75+
76+func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
6277 dir := clipboardImageDir()
6378 if err := os.MkdirAll(dir, 0o700); err != nil {
6479 return "", nil, false, err
@@ -67,7 +82,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
6782 rawPath := path + ".raw"
6883 defer os.Remove(rawPath)
6984
70- kind, err := writeClipboardImageData(rawPath)
85+ kind, err := writeClipboardImageData(ctx, rawPath)
7186 if err != nil {
7287 return "", nil, false, err
7388 }
@@ -90,19 +105,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
90105 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
91106 }
92107 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
108+ if err == errClipboardImageTooLarge {
109+ return "", nil, false, nil
110+ }
93111 return "", nil, false, err
94112 }
95113 }
96114
97- data, err := os.ReadFile(path)
115+ data, err := readClipboardImageFile(path)
116+ if err == errClipboardImageTooLarge {
117+ if err := removeClipboardImageFile(path); err != nil {
118+ return "", nil, false, err
119+ }
120+ return "", nil, false, nil
121+ }
98122 if err != nil {
99123 return "", nil, false, err
100124 }
101125 return path, data, true, nil
102126 }
103127
104-func writeClipboardImageData(path string) (string, error) {
105- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
128+func writeClipboardImageData(ctx context.Context, path string) (string, error) {
129+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
106130 out, err := cmd.CombinedOutput()
107131 trimmed := strings.TrimSpace(string(out))
108132 if err != nil {
@@ -141,10 +165,34 @@ func clipboardImageKind(s string) (string, bool) {
141165 return "", false
142166 }
143167
168+func readClipboardImageFile(path string) ([]byte, error) {
169+ file, err := os.Open(path)
170+ if err != nil {
171+ return nil, err
172+ }
173+ defer file.Close()
174+
175+ data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
176+ if err != nil {
177+ return nil, err
178+ }
179+ if len(data) > maxClipboardImageBytes {
180+ return nil, errClipboardImageTooLarge
181+ }
182+ return data, nil
183+}
184+
185+func removeClipboardImageFile(path string) error {
186+ if err := os.Remove(path); err != nil {
187+ return fmt.Errorf("remove temporary clipboard image: %w", err)
188+ }
189+ return nil
190+}
191+
144192 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
145193 switch strings.ToLower(filepath.Ext(srcPath)) {
146194 case ".png":
147- data, err := os.ReadFile(srcPath)
195+ data, err := readClipboardImageFile(srcPath)
148196 if err != nil {
149197 return err
150198 }
@@ -3,10 +3,12 @@
3 package tui3 package tui
4 4
5 import (5 import (
6+ "context"
6 "crypto/rand"7 "crypto/rand"
7 "encoding/hex"8 "encoding/hex"
8 "fmt"9 "fmt"
9 "image/png"10 "image/png"
11+ "io"
10 "os"12 "os"
11 "os/exec"13 "os/exec"
12 "path/filepath"14 "path/filepath"
@@ -47,18 +49,31 @@ on run argv
47 end run49 end run
48 `50 `
49 51
50-func ReadClipboardImage() (ClipboardImage, bool, error) {52+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
51- _, data, ok, err := ReadClipboardImagePNG()53+ if ctx == nil {
54+ ctx = context.Background()
55+ }
56+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
57+ defer cancel()
58+
59+ path, data, ok, err := readClipboardImagePNG(ctx)
52 if err != nil {60 if err != nil {
53 return ClipboardImage{}, false, err61 return ClipboardImage{}, false, err
54 }62 }
55 if !ok {63 if !ok {
56 return ClipboardImage{}, false, nil64 return ClipboardImage{}, false, nil
57 }65 }
66+ if err := removeClipboardImageFile(path); err != nil {
67+ return ClipboardImage{}, false, err
68+ }
58 return ClipboardImage{MimeType: "image/png", Data: data}, true, nil69 return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
59 }70 }
60 71
61 func ReadClipboardImagePNG() (string, []byte, bool, error) {72 func ReadClipboardImagePNG() (string, []byte, bool, error) {
73+ return readClipboardImagePNG(context.Background())
74+}
75+
76+func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
62 dir := clipboardImageDir()77 dir := clipboardImageDir()
63 if err := os.MkdirAll(dir, 0o700); err != nil {78 if err := os.MkdirAll(dir, 0o700); err != nil {
64 return "", nil, false, err79 return "", nil, false, err
@@ -67,7 +82,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
67 rawPath := path + ".raw"82 rawPath := path + ".raw"
68 defer os.Remove(rawPath)83 defer os.Remove(rawPath)
69 84
70- kind, err := writeClipboardImageData(rawPath)85+ kind, err := writeClipboardImageData(ctx, rawPath)
71 if err != nil {86 if err != nil {
72 return "", nil, false, err87 return "", nil, false, err
73 }88 }
@@ -90,19 +105,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
90 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)105 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
91 }106 }
92 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {107 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
108+ if err == errClipboardImageTooLarge {
109+ return "", nil, false, nil
110+ }
93 return "", nil, false, err111 return "", nil, false, err
94 }112 }
95 }113 }
96 114
97- data, err := os.ReadFile(path)115+ data, err := readClipboardImageFile(path)
116+ if err == errClipboardImageTooLarge {
117+ if err := removeClipboardImageFile(path); err != nil {
118+ return "", nil, false, err
119+ }
120+ return "", nil, false, nil
121+ }
98 if err != nil {122 if err != nil {
99 return "", nil, false, err123 return "", nil, false, err
100 }124 }
101 return path, data, true, nil125 return path, data, true, nil
102 }126 }
103 127
104-func writeClipboardImageData(path string) (string, error) {128+func writeClipboardImageData(ctx context.Context, path string) (string, error) {
105- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)129+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
106 out, err := cmd.CombinedOutput()130 out, err := cmd.CombinedOutput()
107 trimmed := strings.TrimSpace(string(out))131 trimmed := strings.TrimSpace(string(out))
108 if err != nil {132 if err != nil {
@@ -141,10 +165,34 @@ func clipboardImageKind(s string) (string, bool) {
141 return "", false165 return "", false
142 }166 }
143 167
168+func readClipboardImageFile(path string) ([]byte, error) {
169+ file, err := os.Open(path)
170+ if err != nil {
171+ return nil, err
172+ }
173+ defer file.Close()
174+
175+ data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
176+ if err != nil {
177+ return nil, err
178+ }
179+ if len(data) > maxClipboardImageBytes {
180+ return nil, errClipboardImageTooLarge
181+ }
182+ return data, nil
183+}
184+
185+func removeClipboardImageFile(path string) error {
186+ if err := os.Remove(path); err != nil {
187+ return fmt.Errorf("remove temporary clipboard image: %w", err)
188+ }
189+ return nil
190+}
191+
144 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {192 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
145 switch strings.ToLower(filepath.Ext(srcPath)) {193 switch strings.ToLower(filepath.Ext(srcPath)) {
146 case ".png":194 case ".png":
147- data, err := os.ReadFile(srcPath)195+ data, err := readClipboardImageFile(srcPath)
148 if err != nil {196 if err != nil {
149 return err197 return err
150 }198 }
A packages/tui/clipboard_darwin_test.go +42 -0
@@ -0,0 +1,42 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestReadClipboardImageFileSizeLimit(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+ data, err := readClipboardImageFile(path)
18+ if err != nil || len(data) != maxClipboardImageBytes {
19+ t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes)
20+ }
21+
22+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ data, err = readClipboardImageFile(path)
26+ if err != errClipboardImageTooLarge || data != nil {
27+ t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err)
28+ }
29+}
30+
31+func TestRemoveClipboardImageFile(t *testing.T) {
32+ path := filepath.Join(t.TempDir(), "clipboard.png")
33+ if err := os.WriteFile(path, []byte("image"), 0o600); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := removeClipboardImageFile(path); err != nil {
37+ t.Fatalf("removeClipboardImageFile() error = %v", err)
38+ }
39+ if _, err := os.Stat(path); !os.IsNotExist(err) {
40+ t.Fatalf("clipboard image stat error = %v, want not-exist", err)
41+ }
42+}
@@ -0,0 +1,42 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestReadClipboardImageFileSizeLimit(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+ data, err := readClipboardImageFile(path)
18+ if err != nil || len(data) != maxClipboardImageBytes {
19+ t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes)
20+ }
21+
22+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ data, err = readClipboardImageFile(path)
26+ if err != errClipboardImageTooLarge || data != nil {
27+ t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err)
28+ }
29+}
30+
31+func TestRemoveClipboardImageFile(t *testing.T) {
32+ path := filepath.Join(t.TempDir(), "clipboard.png")
33+ if err := os.WriteFile(path, []byte("image"), 0o600); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := removeClipboardImageFile(path); err != nil {
37+ t.Fatalf("removeClipboardImageFile() error = %v", err)
38+ }
39+ if _, err := os.Stat(path); !os.IsNotExist(err) {
40+ t.Fatalf("clipboard image stat error = %v, want not-exist", err)
41+ }
42+}
M packages/tui/clipboard_linux.go +32 -16
@@ -4,14 +4,12 @@ package tui
44
55 import (
66 "context"
7+ "io"
78 "os"
89 "os/exec"
910 "strings"
10- "time"
1111 )
1212
13-const clipboardImageCommandTimeout = 5 * time.Second
14-
1513 var supportedClipboardImageMIMEs = []string{
1614 "image/png",
1715 "image/jpeg",
@@ -27,14 +25,20 @@ type clipboardImageCommand struct {
2725 // ReadClipboardImage reads an image from the Wayland or X11 system clipboard.
2826 // Clipboard helper failures are intentionally treated as an empty clipboard so
2927 // that optional desktop integrations do not affect paste handling.
30-func ReadClipboardImage() (ClipboardImage, bool, error) {
28+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
29+ if ctx == nil {
30+ ctx = context.Background()
31+ }
32+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
33+ defer cancel()
34+
3135 if os.Getenv("WAYLAND_DISPLAY") != "" {
32- if image, ok := readClipboardImageWayland(); ok {
36+ if image, ok := readClipboardImageWayland(ctx); ok {
3337 return image, true, nil
3438 }
3539 }
3640 if os.Getenv("DISPLAY") != "" {
37- if image, ok := readClipboardImageX11(); ok {
41+ if image, ok := readClipboardImageX11(ctx); ok {
3842 return image, true, nil
3943 }
4044 }
@@ -47,8 +51,8 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
4751 return "", nil, false, nil
4852 }
4953
50-func readClipboardImageWayland() (ClipboardImage, bool) {
51- types, ok := runClipboardImageCommand(clipboardImageCommand{
54+func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) {
55+ types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
5256 name: "wl-paste",
5357 args: []string{"--list-types"},
5458 })
@@ -67,7 +71,7 @@ func readClipboardImageWayland() (ClipboardImage, bool) {
6771 if !available[mime] {
6872 continue
6973 }
70- data, ok := runClipboardImageCommand(clipboardImageCommand{
74+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
7175 name: "wl-paste",
7276 args: []string{"--type", mime},
7377 })
@@ -78,9 +82,9 @@ func readClipboardImageWayland() (ClipboardImage, bool) {
7882 return ClipboardImage{}, false
7983 }
8084
81-func readClipboardImageX11() (ClipboardImage, bool) {
85+func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) {
8286 for _, mime := range supportedClipboardImageMIMEs {
83- data, ok := runClipboardImageCommand(clipboardImageCommand{
87+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
8488 name: "xclip",
8589 args: []string{"-selection", "clipboard", "-out", "-target", mime},
8690 })
@@ -100,16 +104,28 @@ func isSupportedClipboardImageMIME(mime string) bool {
100104 return false
101105 }
102106
103-func runClipboardImageCommand(command clipboardImageCommand) ([]byte, bool) {
107+func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) {
104108 path, err := exec.LookPath(command.name)
105109 if err != nil {
106110 return nil, false
107111 }
108112
109- ctx, cancel := context.WithTimeout(context.Background(), clipboardImageCommandTimeout)
110- defer cancel()
111- data, err := exec.CommandContext(ctx, path, command.args...).Output()
112- if err != nil || len(data) == 0 {
113+ cmd := exec.CommandContext(ctx, path, command.args...)
114+ stdout, err := cmd.StdoutPipe()
115+ if err != nil {
116+ return nil, false
117+ }
118+ if err := cmd.Start(); err != nil {
119+ return nil, false
120+ }
121+
122+ data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1))
123+ if readErr != nil || len(data) > maxClipboardImageBytes {
124+ _ = cmd.Process.Kill()
125+ _ = cmd.Wait()
126+ return nil, false
127+ }
128+ if err := cmd.Wait(); err != nil || len(data) == 0 {
113129 return nil, false
114130 }
115131 return data, true
@@ -4,14 +4,12 @@ package tui
4 4
5 import (5 import (
6 "context"6 "context"
7+ "io"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "strings"10 "strings"
10- "time"
11 )11 )
12 12
13-const clipboardImageCommandTimeout = 5 * time.Second
14-
15 var supportedClipboardImageMIMEs = []string{13 var supportedClipboardImageMIMEs = []string{
16 "image/png",14 "image/png",
17 "image/jpeg",15 "image/jpeg",
@@ -27,14 +25,20 @@ type clipboardImageCommand struct {
27 // ReadClipboardImage reads an image from the Wayland or X11 system clipboard.25 // ReadClipboardImage reads an image from the Wayland or X11 system clipboard.
28 // Clipboard helper failures are intentionally treated as an empty clipboard so26 // Clipboard helper failures are intentionally treated as an empty clipboard so
29 // that optional desktop integrations do not affect paste handling.27 // that optional desktop integrations do not affect paste handling.
30-func ReadClipboardImage() (ClipboardImage, bool, error) {28+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
29+ if ctx == nil {
30+ ctx = context.Background()
31+ }
32+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
33+ defer cancel()
34+
31 if os.Getenv("WAYLAND_DISPLAY") != "" {35 if os.Getenv("WAYLAND_DISPLAY") != "" {
32- if image, ok := readClipboardImageWayland(); ok {36+ if image, ok := readClipboardImageWayland(ctx); ok {
33 return image, true, nil37 return image, true, nil
34 }38 }
35 }39 }
36 if os.Getenv("DISPLAY") != "" {40 if os.Getenv("DISPLAY") != "" {
37- if image, ok := readClipboardImageX11(); ok {41+ if image, ok := readClipboardImageX11(ctx); ok {
38 return image, true, nil42 return image, true, nil
39 }43 }
40 }44 }
@@ -47,8 +51,8 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
47 return "", nil, false, nil51 return "", nil, false, nil
48 }52 }
49 53
50-func readClipboardImageWayland() (ClipboardImage, bool) {54+func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) {
51- types, ok := runClipboardImageCommand(clipboardImageCommand{55+ types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
52 name: "wl-paste",56 name: "wl-paste",
53 args: []string{"--list-types"},57 args: []string{"--list-types"},
54 })58 })
@@ -67,7 +71,7 @@ func readClipboardImageWayland() (ClipboardImage, bool) {
67 if !available[mime] {71 if !available[mime] {
68 continue72 continue
69 }73 }
70- data, ok := runClipboardImageCommand(clipboardImageCommand{74+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
71 name: "wl-paste",75 name: "wl-paste",
72 args: []string{"--type", mime},76 args: []string{"--type", mime},
73 })77 })
@@ -78,9 +82,9 @@ func readClipboardImageWayland() (ClipboardImage, bool) {
78 return ClipboardImage{}, false82 return ClipboardImage{}, false
79 }83 }
80 84
81-func readClipboardImageX11() (ClipboardImage, bool) {85+func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) {
82 for _, mime := range supportedClipboardImageMIMEs {86 for _, mime := range supportedClipboardImageMIMEs {
83- data, ok := runClipboardImageCommand(clipboardImageCommand{87+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
84 name: "xclip",88 name: "xclip",
85 args: []string{"-selection", "clipboard", "-out", "-target", mime},89 args: []string{"-selection", "clipboard", "-out", "-target", mime},
86 })90 })
@@ -100,16 +104,28 @@ func isSupportedClipboardImageMIME(mime string) bool {
100 return false104 return false
101 }105 }
102 106
103-func runClipboardImageCommand(command clipboardImageCommand) ([]byte, bool) {107+func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) {
104 path, err := exec.LookPath(command.name)108 path, err := exec.LookPath(command.name)
105 if err != nil {109 if err != nil {
106 return nil, false110 return nil, false
107 }111 }
108 112
109- ctx, cancel := context.WithTimeout(context.Background(), clipboardImageCommandTimeout)113+ cmd := exec.CommandContext(ctx, path, command.args...)
110- defer cancel()114+ stdout, err := cmd.StdoutPipe()
111- data, err := exec.CommandContext(ctx, path, command.args...).Output()115+ if err != nil {
112- if err != nil || len(data) == 0 {116+ return nil, false
117+ }
118+ if err := cmd.Start(); err != nil {
119+ return nil, false
120+ }
121+
122+ data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1))
123+ if readErr != nil || len(data) > maxClipboardImageBytes {
124+ _ = cmd.Process.Kill()
125+ _ = cmd.Wait()
126+ return nil, false
127+ }
128+ if err := cmd.Wait(); err != nil || len(data) == 0 {
113 return nil, false129 return nil, false
114 }130 }
115 return data, true131 return data, true
M packages/tui/clipboard_linux_test.go +31 -3
@@ -3,6 +3,8 @@
33 package tui
44
55 import (
6+ "bytes"
7+ "context"
68 "os"
79 "path/filepath"
810 "testing"
@@ -35,7 +37,7 @@ exit 1
3537 t.Setenv("DISPLAY", "")
3638 t.Setenv("CLIPBOARD_TEST_STATE", state)
3739
38- image, ok, err := ReadClipboardImage()
40+ image, ok, err := ReadClipboardImage(context.Background())
3941 if err != nil {
4042 t.Fatalf("ReadClipboardImage() error = %v", err)
4143 }
@@ -67,7 +69,7 @@ esac
6769 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
6870 t.Setenv("DISPLAY", ":test")
6971
70- image, ok, err := ReadClipboardImage()
72+ image, ok, err := ReadClipboardImage(context.Background())
7173 if err != nil {
7274 t.Fatalf("ReadClipboardImage() error = %v", err)
7375 }
@@ -92,7 +94,7 @@ exit 1
9294 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
9395 t.Setenv("DISPLAY", "")
9496
95- image, ok, err := ReadClipboardImage()
97+ image, ok, err := ReadClipboardImage(context.Background())
9698 if err != nil {
9799 t.Fatalf("ReadClipboardImage() error = %v, want nil", err)
98100 }
@@ -109,6 +111,32 @@ exit 1
109111 }
110112 }
111113
114+func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) {
115+ dir := t.TempDir()
116+ payload := filepath.Join(dir, "payload")
117+ writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh
118+exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD"
119+`)
120+ t.Setenv("PATH", dir)
121+ t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
122+
123+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
124+ t.Fatal(err)
125+ }
126+ data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
127+ if !ok || len(data) != maxClipboardImageBytes {
128+ t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes)
129+ }
130+
131+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+ data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
135+ if ok || data != nil {
136+ t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok)
137+ }
138+}
139+
112140 func writeClipboardTestHelper(t *testing.T, dir, name, script string) {
113141 t.Helper()
114142 path := filepath.Join(dir, name)
@@ -3,6 +3,8 @@
3 package tui3 package tui
4 4
5 import (5 import (
6+ "bytes"
7+ "context"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
8 "testing"10 "testing"
@@ -35,7 +37,7 @@ exit 1
35 t.Setenv("DISPLAY", "")37 t.Setenv("DISPLAY", "")
36 t.Setenv("CLIPBOARD_TEST_STATE", state)38 t.Setenv("CLIPBOARD_TEST_STATE", state)
37 39
38- image, ok, err := ReadClipboardImage()40+ image, ok, err := ReadClipboardImage(context.Background())
39 if err != nil {41 if err != nil {
40 t.Fatalf("ReadClipboardImage() error = %v", err)42 t.Fatalf("ReadClipboardImage() error = %v", err)
41 }43 }
@@ -67,7 +69,7 @@ esac
67 t.Setenv("WAYLAND_DISPLAY", "wayland-test")69 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
68 t.Setenv("DISPLAY", ":test")70 t.Setenv("DISPLAY", ":test")
69 71
70- image, ok, err := ReadClipboardImage()72+ image, ok, err := ReadClipboardImage(context.Background())
71 if err != nil {73 if err != nil {
72 t.Fatalf("ReadClipboardImage() error = %v", err)74 t.Fatalf("ReadClipboardImage() error = %v", err)
73 }75 }
@@ -92,7 +94,7 @@ exit 1
92 t.Setenv("WAYLAND_DISPLAY", "wayland-test")94 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
93 t.Setenv("DISPLAY", "")95 t.Setenv("DISPLAY", "")
94 96
95- image, ok, err := ReadClipboardImage()97+ image, ok, err := ReadClipboardImage(context.Background())
96 if err != nil {98 if err != nil {
97 t.Fatalf("ReadClipboardImage() error = %v, want nil", err)99 t.Fatalf("ReadClipboardImage() error = %v, want nil", err)
98 }100 }
@@ -109,6 +111,32 @@ exit 1
109 }111 }
110 }112 }
111 113
114+func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) {
115+ dir := t.TempDir()
116+ payload := filepath.Join(dir, "payload")
117+ writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh
118+exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD"
119+`)
120+ t.Setenv("PATH", dir)
121+ t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
122+
123+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
124+ t.Fatal(err)
125+ }
126+ data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
127+ if !ok || len(data) != maxClipboardImageBytes {
128+ t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes)
129+ }
130+
131+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+ data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
135+ if ok || data != nil {
136+ t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok)
137+ }
138+}
139+
112 func writeClipboardTestHelper(t *testing.T, dir, name, script string) {140 func writeClipboardTestHelper(t *testing.T, dir, name, script string) {
113 t.Helper()141 t.Helper()
114 path := filepath.Join(dir, name)142 path := filepath.Join(dir, name)
M packages/tui/clipboard_stub.go +3 -1
@@ -2,7 +2,9 @@
22
33 package tui
44
5-func ReadClipboardImage() (ClipboardImage, bool, error) {
5+import "context"
6+
7+func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) {
68 return ClipboardImage{}, false, nil
79 }
810
@@ -2,7 +2,9 @@
2 2
3 package tui3 package tui
4 4
5-func ReadClipboardImage() (ClipboardImage, bool, error) {5+import "context"
6+
7+func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) {
6 return ClipboardImage{}, false, nil8 return ClipboardImage{}, false, nil
7 }9 }
8 10

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close