fix(tui): bound clipboard image reads
- commit
- 3959425d5083c375d8b81d94737a90e026286584
- author
- brice · · 2026-08-04 09:40 UTC
- parent
- 3346322
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 761 | 761 | ||
| 762 | 762 | ## 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 | 766 | ## Inline images | |
| 767 | 767 | ||
| @@ -761,7 +761,7 @@ A model installed through Ollama is kept in Ollama's internal storage and is not | |||||
| 761 | 761 | ||||
| 762 | ## Clipboard images | 762 | ## 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 images | 766 | ## 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) { | |||
| 2474 | 2474 | i.invalidate() | |
| 2475 | 2475 | return false | |
| 2476 | 2476 | case tui.KeyPasteClipboard: | |
| 2477 | - | i.pasteClipboard() | |
| 2477 | + | i.pasteClipboard(ctx) | |
| 2478 | 2478 | return false | |
| 2479 | 2479 | case tui.KeyCtrlO: | |
| 2480 | 2480 | i.toggleToolExpansion() | |
| @@ -2766,8 +2766,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2766 | 2766 | return false | |
| 2767 | 2767 | } | |
| 2768 | 2768 | ||
| 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) | |
| 2771 | 2771 | if err != nil { | |
| 2772 | 2772 | i.mu.Lock() | |
| 2773 | 2773 | 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 false | 2475 | return false | ||
| 2476 | case tui.KeyPasteClipboard: | 2476 | case tui.KeyPasteClipboard: | ||
| 2477 | - | i.pasteClipboard() | 2477 | + | i.pasteClipboard(ctx) |
| 2478 | return false | 2478 | 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 false | 2766 | 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 @@ | |||
| 1 | 1 | 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 | 15 | // ClipboardImage is an image read from the system clipboard. | |
| 4 | 16 | type ClipboardImage struct { | |
| 5 | 17 | MimeType string | |
| @@ -1,5 +1,17 @@ | |||||
| 1 | package tui | 1 | 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 string | 17 | MimeType string | ||
M packages/tui/clipboard_darwin.go +55 -7
| @@ -3,10 +3,12 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "crypto/rand" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "image/png" | |
| 11 | + | "io" | |
| 10 | 12 | "os" | |
| 11 | 13 | "os/exec" | |
| 12 | 14 | "path/filepath" | |
| @@ -47,18 +49,31 @@ on run argv | |||
| 47 | 49 | end run | |
| 48 | 50 | ` | |
| 49 | 51 | ||
| 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) | |
| 52 | 60 | if err != nil { | |
| 53 | 61 | return ClipboardImage{}, false, err | |
| 54 | 62 | } | |
| 55 | 63 | if !ok { | |
| 56 | 64 | return ClipboardImage{}, false, nil | |
| 57 | 65 | } | |
| 66 | + | if err := removeClipboardImageFile(path); err != nil { | |
| 67 | + | return ClipboardImage{}, false, err | |
| 68 | + | } | |
| 58 | 69 | return ClipboardImage{MimeType: "image/png", Data: data}, true, nil | |
| 59 | 70 | } | |
| 60 | 71 | ||
| 61 | 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 | 77 | dir := clipboardImageDir() | |
| 63 | 78 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 64 | 79 | return "", nil, false, err | |
| @@ -67,7 +82,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 67 | 82 | rawPath := path + ".raw" | |
| 68 | 83 | defer os.Remove(rawPath) | |
| 69 | 84 | ||
| 70 | - | kind, err := writeClipboardImageData(rawPath) | |
| 85 | + | kind, err := writeClipboardImageData(ctx, rawPath) | |
| 71 | 86 | if err != nil { | |
| 72 | 87 | return "", nil, false, err | |
| 73 | 88 | } | |
| @@ -90,19 +105,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 90 | 105 | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |
| 91 | 106 | } | |
| 92 | 107 | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |
| 108 | + | if err == errClipboardImageTooLarge { | |
| 109 | + | return "", nil, false, nil | |
| 110 | + | } | |
| 93 | 111 | 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 | 122 | if err != nil { | |
| 99 | 123 | return "", nil, false, err | |
| 100 | 124 | } | |
| 101 | 125 | return path, data, true, nil | |
| 102 | 126 | } | |
| 103 | 127 | ||
| 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) | |
| 106 | 130 | out, err := cmd.CombinedOutput() | |
| 107 | 131 | trimmed := strings.TrimSpace(string(out)) | |
| 108 | 132 | if err != nil { | |
| @@ -141,10 +165,34 @@ func clipboardImageKind(s string) (string, bool) { | |||
| 141 | 165 | 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 | 192 | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |
| 145 | 193 | switch strings.ToLower(filepath.Ext(srcPath)) { | |
| 146 | 194 | case ".png": | |
| 147 | - | data, err := os.ReadFile(srcPath) | |
| 195 | + | data, err := readClipboardImageFile(srcPath) | |
| 148 | 196 | if err != nil { | |
| 149 | 197 | return err | |
| 150 | 198 | } | |
| @@ -3,10 +3,12 @@ | |||||
| 3 | package tui | 3 | 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 run | 49 | 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, err | 61 | return ClipboardImage{}, false, err | ||
| 54 | } | 62 | } | ||
| 55 | if !ok { | 63 | if !ok { | ||
| 56 | return ClipboardImage{}, false, nil | 64 | 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, nil | 69 | 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, err | 79 | 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, err | 87 | 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, err | 111 | 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, err | 123 | return "", nil, false, err | ||
| 100 | } | 124 | } | ||
| 101 | return path, data, true, nil | 125 | 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 "", false | 165 | 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 err | 197 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "strings" | |
| 10 | - | "time" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | const clipboardImageCommandTimeout = 5 * time.Second | |
| 14 | - | ||
| 15 | 13 | var supportedClipboardImageMIMEs = []string{ | |
| 16 | 14 | "image/png", | |
| 17 | 15 | "image/jpeg", | |
| @@ -27,14 +25,20 @@ type clipboardImageCommand struct { | |||
| 27 | 25 | // ReadClipboardImage reads an image from the Wayland or X11 system clipboard. | |
| 28 | 26 | // Clipboard helper failures are intentionally treated as an empty clipboard so | |
| 29 | 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 | 35 | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 32 | - | if image, ok := readClipboardImageWayland(); ok { | |
| 36 | + | if image, ok := readClipboardImageWayland(ctx); ok { | |
| 33 | 37 | return image, true, nil | |
| 34 | 38 | } | |
| 35 | 39 | } | |
| 36 | 40 | if os.Getenv("DISPLAY") != "" { | |
| 37 | - | if image, ok := readClipboardImageX11(); ok { | |
| 41 | + | if image, ok := readClipboardImageX11(ctx); ok { | |
| 38 | 42 | return image, true, nil | |
| 39 | 43 | } | |
| 40 | 44 | } | |
| @@ -47,8 +51,8 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 47 | 51 | return "", nil, false, nil | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | - | func readClipboardImageWayland() (ClipboardImage, bool) { | |
| 51 | - | types, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 54 | + | func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) { | |
| 55 | + | types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 52 | 56 | name: "wl-paste", | |
| 53 | 57 | args: []string{"--list-types"}, | |
| 54 | 58 | }) | |
| @@ -67,7 +71,7 @@ func readClipboardImageWayland() (ClipboardImage, bool) { | |||
| 67 | 71 | if !available[mime] { | |
| 68 | 72 | continue | |
| 69 | 73 | } | |
| 70 | - | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 74 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 71 | 75 | name: "wl-paste", | |
| 72 | 76 | args: []string{"--type", mime}, | |
| 73 | 77 | }) | |
| @@ -78,9 +82,9 @@ func readClipboardImageWayland() (ClipboardImage, bool) { | |||
| 78 | 82 | return ClipboardImage{}, false | |
| 79 | 83 | } | |
| 80 | 84 | ||
| 81 | - | func readClipboardImageX11() (ClipboardImage, bool) { | |
| 85 | + | func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) { | |
| 82 | 86 | for _, mime := range supportedClipboardImageMIMEs { | |
| 83 | - | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 87 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 84 | 88 | name: "xclip", | |
| 85 | 89 | args: []string{"-selection", "clipboard", "-out", "-target", mime}, | |
| 86 | 90 | }) | |
| @@ -100,16 +104,28 @@ func isSupportedClipboardImageMIME(mime string) bool { | |||
| 100 | 104 | return false | |
| 101 | 105 | } | |
| 102 | 106 | ||
| 103 | - | func runClipboardImageCommand(command clipboardImageCommand) ([]byte, bool) { | |
| 107 | + | func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) { | |
| 104 | 108 | path, err := exec.LookPath(command.name) | |
| 105 | 109 | if err != nil { | |
| 106 | 110 | return nil, false | |
| 107 | 111 | } | |
| 108 | 112 | ||
| 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 { | |
| 113 | 129 | return nil, false | |
| 114 | 130 | } | |
| 115 | 131 | 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 so | 26 | // 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, nil | 37 | 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, nil | 42 | return image, true, nil | ||
| 39 | } | 43 | } | ||
| 40 | } | 44 | } | ||
| @@ -47,8 +51,8 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||||
| 47 | return "", nil, false, nil | 51 | 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 | continue | 72 | 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{}, false | 82 | 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 false | 104 | 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, false | 110 | 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, false | 129 | return nil, false | ||
| 114 | } | 130 | } | ||
| 115 | return data, true | 131 | return data, true | ||
M packages/tui/clipboard_linux_test.go +31 -3
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "testing" | |
| @@ -35,7 +37,7 @@ exit 1 | |||
| 35 | 37 | t.Setenv("DISPLAY", "") | |
| 36 | 38 | t.Setenv("CLIPBOARD_TEST_STATE", state) | |
| 37 | 39 | ||
| 38 | - | image, ok, err := ReadClipboardImage() | |
| 40 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 39 | 41 | if err != nil { | |
| 40 | 42 | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 41 | 43 | } | |
| @@ -67,7 +69,7 @@ esac | |||
| 67 | 69 | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 68 | 70 | t.Setenv("DISPLAY", ":test") | |
| 69 | 71 | ||
| 70 | - | image, ok, err := ReadClipboardImage() | |
| 72 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 71 | 73 | if err != nil { | |
| 72 | 74 | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 73 | 75 | } | |
| @@ -92,7 +94,7 @@ exit 1 | |||
| 92 | 94 | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 93 | 95 | t.Setenv("DISPLAY", "") | |
| 94 | 96 | ||
| 95 | - | image, ok, err := ReadClipboardImage() | |
| 97 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 96 | 98 | if err != nil { | |
| 97 | 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 | 140 | func writeClipboardTestHelper(t *testing.T, dir, name, script string) { | |
| 113 | 141 | t.Helper() | |
| 114 | 142 | path := filepath.Join(dir, name) | |
| @@ -3,6 +3,8 @@ | |||||
| 3 | package tui | 3 | 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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | - | func ReadClipboardImage() (ClipboardImage, bool, error) { | |
| 5 | + | import "context" | |
| 6 | + | ||
| 7 | + | func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) { | |
| 6 | 8 | return ClipboardImage{}, false, nil | |
| 7 | 9 | } | |
| 8 | 10 | ||
| @@ -2,7 +2,9 @@ | |||||
| 2 | 2 | ||||
| 3 | package tui | 3 | 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, nil | 8 | return ClipboardImage{}, false, nil | ||
| 7 | } | 9 | } | ||
| 8 | 10 | ||||