clipboard_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"io"
8	"os"
9	"path/filepath"
10	"slices"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/stretchr/testify/mock"
17	"github.com/stretchr/testify/require"
18
19	"github.com/bnema/vev/internal/adapters/sessionwire"
20	"github.com/bnema/vev/internal/domain"
21	"github.com/bnema/vev/internal/ports"
22	portsmocks "github.com/bnema/vev/internal/ports/mocks"
23	"github.com/bnema/vev/internal/protocol"
24	"github.com/bnema/vev/internal/protocol/wire"
25	scopy "github.com/bnema/vev/internal/usecase/copy"
26)
27
28func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
29	normalWrites := make(chan []byte, 1)
30	floatingWrites := make(chan []byte, 1)
31	normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
32	floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
33	defer releaseNormal()
34	defer releaseFloating()
35	d, sess, _, _ := newManualSessionWithPTYs(t, normal)
36	floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
37	floating.screen.Write([]byte("\x1b[?2004h"))
38	installTestFloating(testAttachmentTab(sess), floating, true)
39
40	d.injectClipboardPath(sess, "/tmp/image.png")
41
42	requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~"))
43	requireNoPTYWrite(t, normalWrites)
44}
45
46func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
47	writes := make(chan []byte, 1)
48	p, releasePTY := newBlockingPTYWithWrites(t, writes)
49	defer releasePTY()
50	d, sess, _, _ := newManualSessionWithPTYs(t, p)
51	d.tempDir = t.TempDir()
52
53	data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
54	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: data})
55
56	injected := <-writes
57	path := string(injected)
58	require.FileExists(t, path)
59	require.Equal(t, ".png", filepath.Ext(path))
60	require.True(t, filepath.IsAbs(path))
61
62	got, err := os.ReadFile(path)
63	require.NoError(t, err)
64	require.Equal(t, data, got)
65
66	info, err := os.Stat(path)
67	require.NoError(t, err)
68	require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
69}
70
71func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
72	writes := make(chan []byte, 1)
73	p, releasePTY := newBlockingPTYWithWrites(t, writes)
74	defer releasePTY()
75	d, sess, _, _ := newManualSessionWithPTYs(t, p)
76	d.tempDir = t.TempDir()
77	pane := sess.tabs[0].focusedPane()
78	require.False(t, pane.screen.BracketedPasteMode())
79
80	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: []byte("x")})
81
82	injected := <-writes
83	require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
84	require.FileExists(t, string(injected))
85}
86
87func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
88	writes := make(chan []byte, 1)
89	p, releasePTY := newBlockingPTYWithWrites(t, writes)
90	defer releasePTY()
91	d, sess, _, _ := newManualSessionWithPTYs(t, p)
92	d.tempDir = t.TempDir()
93	pane := sess.tabs[0].focusedPane()
94	pane.screen.Write([]byte("\x1b[?2004h"))
95	require.True(t, pane.screen.BracketedPasteMode())
96
97	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: []byte("x")})
98
99	injected := <-writes
100	require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
101	require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
102	require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
103	path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
104	require.FileExists(t, path)
105}
106
107func hasBracketedPasteMarkers(b []byte) bool {
108	return len(b) >= len(clipPasteOpenMarker) &&
109		string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
110}
111
112func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
113	writes := make(chan []byte, 1)
114	p, releasePTY := newBlockingPTYWithWrites(t, writes)
115	defer releasePTY()
116	d, sess, _, _ := newManualSessionWithPTYs(t, p)
117	dir := t.TempDir()
118	d.tempDir = dir
119
120	huge := make([]byte, maxImagePushSize+1)
121	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: huge})
122
123	select {
124	case w := <-writes:
125		t.Fatalf("oversized push must not be injected into the pane, got %q", w)
126	default:
127	}
128	entries, err := os.ReadDir(dir)
129	require.NoError(t, err)
130	require.Empty(t, entries, "oversized push must not write a temp file")
131}
132
133func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
134	writes := make(chan []byte, 1)
135	p, releasePTY := newBlockingPTYWithWrites(t, writes)
136	defer releasePTY()
137	d, sess, _, _ := newManualSessionWithPTYs(t, p)
138	dir := t.TempDir()
139	d.tempDir = dir
140
141	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: nil})
142
143	select {
144	case w := <-writes:
145		t.Fatalf("empty push must not be injected, got %q", w)
146	default:
147	}
148	entries, err := os.ReadDir(dir)
149	require.NoError(t, err)
150	require.Empty(t, entries)
151}
152
153func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
154	cases := map[string]string{
155		"image/png":                "png",
156		"image/jpeg":               "jpg",
157		"image/webp":               "webp",
158		"image/gif":                "gif",
159		"image/bmp":                "bin",
160		"":                         "bin",
161		"application/octet-stream": "bin",
162	}
163	for mime, want := range cases {
164		require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
165	}
166}
167
168func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
169	writes := make(chan []byte, 1)
170	p, releasePTY := newBlockingPTYWithWrites(t, writes)
171	defer releasePTY()
172	d, sess, _, _ := newManualSessionWithPTYs(t, p)
173	d.tempDir = t.TempDir()
174	// killSession requires the session to be registered in the daemon's
175	// registry (newManualSessionWithPTYs already does this via d.sessions).
176
177	d.handleImagePush(sess, protocol.ImagePush{Mime: "image/png", Data: []byte("x")})
178	injected := <-writes
179	path := string(injected)
180	require.FileExists(t, path)
181
182	require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
183	require.NoFileExists(t, path)
184}
185
186// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
187// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
188// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
189func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
190	t.Helper()
191	p := portsmocks.NewMockPTY(t)
192	p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
193		if len(chunks) == 0 {
194			return 0, io.EOF
195		}
196		n := copy(buf, chunks[0])
197		if n == len(chunks[0]) {
198			chunks = chunks[1:]
199		} else {
200			chunks[0] = chunks[0][n:]
201		}
202		return n, nil
203	})
204	p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
205	p.EXPECT().Close().Return(nil).Maybe()
206	return p
207}
208
209func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.ServerConnection) {
210	rc := d.attachCoordinator(sess, nil, ac, true)
211	token := sess.captureAttachmentCapability(ac, tr)
212	token.lease = rc.attachmentLease(ac)
213	ac.installTestAttachmentCapability(token)
214}
215
216func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
217	t.Helper()
218	tb := testAttachmentTab(sess)
219	tb.mu.Lock()
220	p := tb.terminalTargetLocked()
221	tb.mu.Unlock()
222	if p == nil {
223		t.Fatal("clipboard owner has no terminal target")
224	}
225	return p.effectLease()
226}
227
228func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
229	d := newTestDaemon(t, nil, stubClock{})
230	p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
231
232	tr, sends := newCapturingTransport(t)
233	sctx, cancel := context.WithCancel(context.Background())
234	defer cancel()
235	win := newTestTabWithContext(p, sctx, cancel)
236	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
237	ac.initOverlays()
238	sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
239	publishTiledPaneOwners(sess, win)
240	ac.setSession(sess)
241	d.sessions[sess.id] = sess
242	publishActiveClipboardCapability(d, sess, ac, tr)
243
244	d.sessWg.Add(1)
245	d.ptyReader(sess, win, win.focusedPane())
246
247	var clipboardOutput string
248	for range 3 {
249		f := awaitFrame(t, sends, "Output")
250		out, ok := decodeServerMessage(t, f).(protocol.Output)
251		require.True(t, ok)
252		clipboardOutput = string(out.Data)
253		if strings.Contains(clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") {
254			break
255		}
256	}
257	require.Contains(t, clipboardOutput, "\x1b]52;c;aGVsbG8=\x07")
258}
259
260func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
261	d := newTestDaemon(t, nil, stubClock{})
262	big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
263	b64 := base64.StdEncoding.EncodeToString([]byte(big))
264	p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
265
266	tr, sends := newCapturingTransport(t)
267	sctx, cancel := context.WithCancel(context.Background())
268	defer cancel()
269	win := newTestTabWithContext(p, sctx, cancel)
270	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
271	ac.initOverlays()
272	sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
273	publishTiledPaneOwners(sess, win)
274	ac.setSession(sess)
275	d.sessions[sess.id] = sess
276
277	d.sessWg.Add(1)
278	d.ptyReader(sess, win, win.focusedPane())
279
280	select {
281	case f := <-sends:
282		require.NotEqual(t, "Output", envelopeMessageName(t, f.Payload))
283	default:
284	}
285}
286
287func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
288	d := newTestDaemon(t, nil, stubClock{})
289	p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
290
291	tr, sends := newCapturingTransport(t)
292	sctx, cancel := context.WithCancel(context.Background())
293	defer cancel()
294	win := newTestTabWithContext(p, sctx, cancel)
295	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
296	ac.initOverlays()
297	sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
298	publishTiledPaneOwners(sess, win)
299	ac.setSession(sess)
300	d.sessions[sess.id] = sess
301
302	d.sessWg.Add(1)
303	d.ptyReader(sess, win, win.focusedPane())
304
305	select {
306	case f := <-sends:
307		require.NotEqual(t, "Output", envelopeMessageName(t, f.Payload))
308	default:
309	}
310}
311
312func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
313	d := newTestDaemon(t, nil, stubClock{})
314	p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
315
316	sctx, cancel := context.WithCancel(context.Background())
317	defer cancel()
318	win := newTestTabWithContext(p, sctx, cancel)
319	sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
320	publishTiledPaneOwners(sess, win)
321	d.sessions[sess.id] = sess
322
323	d.sessWg.Add(1)
324	require.NotPanics(t, func() {
325		d.ptyReader(sess, win, win.focusedPane())
326	})
327}
328
329type staleClipboardErrorTransport struct {
330	mu    sync.Mutex
331	sends int
332}
333
334func (t *staleClipboardErrorTransport) Send(wire.Envelope) error {
335	t.mu.Lock()
336	t.sends++
337	t.mu.Unlock()
338	return errors.New("stale clipboard send")
339}
340
341func (*staleClipboardErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
342func (*staleClipboardErrorTransport) Close() error                 { return nil }
343
344func (t *staleClipboardErrorTransport) sendCount() int {
345	t.mu.Lock()
346	defer t.mu.Unlock()
347	return t.sends
348}
349
350func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
351	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
352	oldTransport := &staleClipboardErrorTransport{}
353	ac.replaceTransport(oldTransport)
354	publishActiveClipboardCapability(d, sess, ac, oldTransport)
355
356	sess.mu.Lock()
357	sess.clipboardWorkerRunning = true
358	sess.mu.Unlock()
359	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
360
361	sourceTab := testAttachmentTab(sess)
362	p := sourceTab.focusedPane()
363	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
364	publishPaneOwner(p, destination, &tab{}, 0)
365	publishPaneOwner(p, sess, sourceTab, 0)
366
367	d.clipboardWorker(sess)
368
369	require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
370	require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale clipboard send handling detached the former owner's client")
371}
372
373type movingClipboardErrorTransport struct {
374	started     chan struct{}
375	release     chan struct{}
376	closed      chan struct{}
377	startedOnce sync.Once
378	closeOnce   sync.Once
379}
380
381func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
382	return &movingClipboardErrorTransport{
383		started: make(chan struct{}),
384		release: make(chan struct{}),
385		closed:  make(chan struct{}),
386	}
387}
388
389func (t *movingClipboardErrorTransport) Send(wire.Envelope) error {
390	t.startedOnce.Do(func() { close(t.started) })
391	<-t.release
392	return errors.New("clipboard send failed after pane move")
393}
394
395func (*movingClipboardErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
396
397func (t *movingClipboardErrorTransport) Close() error {
398	t.closeOnce.Do(func() { close(t.closed) })
399	return nil
400}
401
402func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing.T) {
403	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
404	oldTransport := &staleClipboardErrorTransport{}
405	ac.replaceTransport(oldTransport)
406	publishActiveClipboardCapability(d, sess, ac, oldTransport)
407
408	sess.mu.Lock()
409	sess.clipboardWorkerRunning = true
410	sess.mu.Unlock()
411	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
412
413	ac.sendMu.Lock()
414	sendMuLocked := true
415	defer func() {
416		if sendMuLocked {
417			ac.sendMu.Unlock()
418		}
419	}()
420	workerDone := make(chan struct{})
421	go func() {
422		d.clipboardWorker(sess)
423		close(workerDone)
424	}()
425	require.Eventually(t, func() bool {
426		return ac.lifecycle.inFlightCount() == 1
427	}, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
428
429	sourceTab := testAttachmentTab(sess)
430	p := sourceTab.focusedPane()
431	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
432	publishPaneOwner(p, destination, &tab{}, 0)
433	publishPaneOwner(p, sess, sourceTab, 0)
434	ac.sendMu.Unlock()
435	sendMuLocked = false
436	awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
437
438	require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
439	require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
440}
441
442func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
443	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
444	oldTransport := newMovingClipboardErrorTransport()
445	ac.replaceTransport(oldTransport)
446	publishActiveClipboardCapability(d, sess, ac, oldTransport)
447
448	sess.mu.Lock()
449	sess.clipboardWorkerRunning = true
450	sess.mu.Unlock()
451	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
452	sourceTab := testAttachmentTab(sess)
453	p := sourceTab.focusedPane()
454
455	workerDone := make(chan struct{})
456	go func() {
457		d.clipboardWorker(sess)
458		close(workerDone)
459	}()
460	awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
461
462	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
463	publishPaneOwner(p, destination, &tab{}, 0)
464	publishPaneOwner(p, sess, sourceTab, 0)
465	close(oldTransport.release)
466	awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
467
468	require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "a send error from the pane's retired owner detached its client")
469	select {
470	case <-oldTransport.closed:
471		t.Fatal("a send error from the pane's retired owner closed its client transport")
472	default:
473	}
474}
475
476func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
477	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
478	tr := newBlockingClipboardTransport()
479	ac.replaceTransport(tr)
480	publishActiveClipboardCapability(d, sess, ac, tr)
481	owner := clipboardOwnerLease(t, sess)
482
483	first := base64.StdEncoding.EncodeToString([]byte("first"))
484	second := base64.StdEncoding.EncodeToString([]byte("second"))
485	d.forwardClipboardAsync(owner, first)
486	require.Equal(t, "first", <-tr.started)
487
488	d.forwardClipboardAsync(owner, second)
489	select {
490	case got := <-tr.started:
491		t.Fatalf("second clipboard send started before first completed: %q", got)
492	case <-time.After(50 * time.Millisecond):
493	}
494
495	close(tr.releaseFirst)
496	require.Equal(t, "first", <-tr.sent)
497	require.Equal(t, "second", <-tr.started)
498	require.Equal(t, "second", <-tr.sent)
499}
500
501type blockingClipboardTransport struct {
502	started      chan string
503	sent         chan string
504	releaseFirst chan struct{}
505}
506
507func newBlockingClipboardTransport() *blockingClipboardTransport {
508	return &blockingClipboardTransport{
509		started:      make(chan string, 2),
510		sent:         make(chan string, 2),
511		releaseFirst: make(chan struct{}),
512	}
513}
514
515func (tr *blockingClipboardTransport) Send(f wire.Envelope) error {
516	message, err := sessionwire.DecodeServerEnvelope(f.Payload)
517	if err != nil {
518		return err
519	}
520	out, ok := message.(protocol.Output)
521	if !ok {
522		return nil
523	}
524	data := string(out.Data)
525	var label string
526	switch {
527	case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
528		label = "first"
529	case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
530		label = "second"
531	default:
532		label = data
533	}
534	tr.started <- label
535	if label == "first" {
536		<-tr.releaseFirst
537	}
538	tr.sent <- label
539	return nil
540}
541
542func (tr *blockingClipboardTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
543func (tr *blockingClipboardTransport) Close() error                 { return nil }

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 12

Tags 11

↑↓ moveenter openesc close