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