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 }