1package ports
2
3import (
4 "bytes"
5 "fmt"
6 "math"
7 "reflect"
8 "testing"
9
10 renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/stretchr/testify/require"
13)
14
15// mustNotPanic runs f and fails the test (instead of crashing the test
16// binary) if f panics.
17func mustNotPanic(t *testing.T, f func()) {
18 t.Helper()
19 defer func() {
20 if r := recover(); r != nil {
21 t.Fatalf("panicked: %v", r)
22 }
23 }()
24 f()
25}
26
27// assertAllPrefixesFail decodes every strict prefix of full and requires an
28// error (never a panic, never a successful decode) for each one. This
29// exercises truncation at every possible boundary — mid-length,
30// mid-string-len, mid-string — in one sweep.
31func assertAllPrefixesFail[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
32 t.Helper()
33 for n := range len(full) {
34 t.Run(fmt.Sprintf("truncated_at_%d", n), func(t *testing.T) {
35 mustNotPanic(t, func() {
36 if _, err := unmarshal(full[:n]); err == nil {
37 t.Fatalf("expected error decoding %d/%d truncated bytes, got nil", n, len(full))
38 }
39 })
40 })
41 }
42}
43
44// assertTrailingGarbageFails decodes full with extra bytes appended and
45// requires an error for a fixed-shape message.
46func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
47 t.Helper()
48 garbage := append(append([]byte(nil), full...), 0xff)
49 mustNotPanic(t, func() {
50 if _, err := unmarshal(garbage); err == nil {
51 t.Fatalf("expected error decoding payload with trailing garbage, got nil")
52 }
53 })
54}
55
56func appendNoNavigationTail(payload []byte) []byte {
57 // exact-target absent, preferred-tab absent, remote-origin absent,
58 // navigation capabilities none, startup overlay none.
59 return append(payload, 0, 0, 0, 0, 0, 0, 0)
60}
61
62func TestHelloGoldenAndRoundTrip(t *testing.T) {
63 tests := []struct {
64 name string
65 msg Hello
66 want []byte
67 }{
68 {
69 name: "typical",
70 msg: Hello{
71 Version: 1,
72 Intent: IntentNew,
73 ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
74 ResumeToken: 0x0102030405060708,
75 Name: "w0",
76 Size: domain.Size{Cols: 80, Rows: 24},
77 TermEnv: "xterm-256color",
78 Cwd: "/tmp/project",
79 TrueColor: true,
80 MaxOutputInFlight: 8,
81 },
82 want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
83 },
84 {
85 name: "empty strings",
86 msg: Hello{
87 Version: 1,
88 Intent: IntentEphemeral,
89 Name: "",
90 Size: domain.Size{Cols: 1, Rows: 1},
91 TermEnv: "",
92 Cwd: "",
93 TrueColor: false,
94 },
95 want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
96 },
97 }
98
99 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {
101 want := appendNoNavigationTail(append([]byte(nil), tt.want...))
102 got := MarshalHello(tt.msg)
103 if !bytes.Equal(got, want) {
104 t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
105 }
106 back, err := UnmarshalHello(got)
107 if err != nil {
108 t.Fatalf("UnmarshalHello() error = %v", err)
109 }
110 if !reflect.DeepEqual(back, tt.msg) {
111 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
112 }
113 peek, ok := PeekHelloVersion(got)
114 if !ok || peek != tt.msg.Version {
115 t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
116 }
117 })
118 }
119
120 full := MarshalHello(tests[0].msg)
121 assertAllPrefixesFail(t, full, UnmarshalHello)
122 assertTrailingGarbageFails(t, full, UnmarshalHello)
123}
124
125func TestHelloEnvironmentCodec(t *testing.T) {
126 t.Run("literal wire layout", func(t *testing.T) {
127 msg := Hello{
128 Version: ProtocolVersion,
129 Size: domain.Size{Cols: 1, Rows: 1},
130 Env: []string{"A=B", "XY=123"},
131 }
132 got := MarshalHello(msg)
133 want := []byte{
134 0x00, 0x1d, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
138 0x00, 0x00, // name
139 0x00, 0x01, 0x00, 0x01, // size
140 0x00, 0x00, // TERM
141 0x00, 0x00, // cwd
142 0x00, 0x00, // true color, max output in flight
143 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
144 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
145 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
146 0x00, 0x00, // no remote target, client-owned environment
147 }
148 want = appendNoNavigationTail(want)
149 require.Equal(t, want, got)
150 decoded, err := UnmarshalHello(want)
151 require.NoError(t, err)
152 require.Equal(t, msg, decoded)
153 assertAllPrefixesFail(t, want, UnmarshalHello)
154 assertTrailingGarbageFails(t, want, UnmarshalHello)
155 })
156
157 t.Run("lossless order and values", func(t *testing.T) {
158 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
159 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
160 payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: want})
161 got, err := UnmarshalHello(payload)
162 require.NoError(t, err)
163 require.Equal(t, want, got.Env)
164 })
165
166 t.Run("empty", func(t *testing.T) {
167 payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: []string{}})
168 got, err := UnmarshalHello(payload)
169 require.NoError(t, err)
170 require.Empty(t, got.Env)
171 })
172
173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
174 withCount := func(count byte) []byte {
175 payload := append([]byte(nil), base...)
176 // The uint32 environment count occupies bytes len-13 through len-10.
177 payload[len(payload)-10] = count
178 return payload
179 }
180 withEntries := func(count byte, entries ...byte) []byte {
181 payload := withCount(count)
182 // The nine-byte tail follows the environment-count field.
183 tail := append([]byte(nil), payload[len(payload)-9:]...)
184 payload = append(payload[:len(payload)-9], entries...)
185 return append(payload, tail...)
186 }
187 tests := []struct {
188 name string
189 payload []byte
190 }{
191 {name: "truncated count", payload: base[:len(base)-1]},
192 {name: "truncated entry length", payload: withEntries(1, 0, 0)},
193 {name: "truncated entry body", payload: withEntries(1, 0, 0, 0, 3, 'x')},
194 {name: "impossible count", payload: withCount(2)},
195 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
196 }
197 for _, tt := range tests {
198 t.Run(tt.name, func(t *testing.T) {
199 _, err := UnmarshalHello(tt.payload)
200 require.Error(t, err)
201 if tt.name != "trailing garbage" {
202 require.ErrorIs(t, err, errShortPayload)
203 }
204 })
205 }
206}
207
208func TestInputGoldenAndRoundTrip(t *testing.T) {
209 tests := []struct {
210 name string
211 msg Input
212 want []byte
213 }{
214 {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
215 {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
216 }
217
218 for _, tt := range tests {
219 t.Run(tt.name, func(t *testing.T) {
220 got := MarshalInput(tt.msg)
221 if !bytes.Equal(got, tt.want) {
222 t.Fatalf("MarshalInput() = %#v, want %#v", got, tt.want)
223 }
224 back, err := UnmarshalInput(got)
225 if err != nil {
226 t.Fatalf("UnmarshalInput() error = %v", err)
227 }
228 if !reflect.DeepEqual(back, tt.msg) {
229 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
230 }
231 })
232 }
233
234 full := MarshalInput(tests[0].msg)
235 assertAllPrefixesFail(t, full[:8], UnmarshalInput)
236}
237
238func TestImagePushGoldenAndRoundTrip(t *testing.T) {
239 tests := []struct {
240 name string
241 msg ImagePush
242 want []byte
243 }{
244 {
245 name: "png data",
246 msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
247 want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
248 },
249 {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
250 }
251
252 for _, tt := range tests {
253 t.Run(tt.name, func(t *testing.T) {
254 got := MarshalImagePush(tt.msg)
255 if !bytes.Equal(got, tt.want) {
256 t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
257 }
258 back, err := UnmarshalImagePush(got)
259 if err != nil {
260 t.Fatalf("UnmarshalImagePush() error = %v", err)
261 }
262 if !reflect.DeepEqual(back, tt.msg) {
263 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
264 }
265 })
266 }
267
268 full := MarshalImagePush(tests[0].msg)
269 assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
270}
271
272func TestThemeGoldenAndRoundTrip(t *testing.T) {
273 palette := [16]renderer.RGB{
274 {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
275 {R: 0x17, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
276 {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
277 {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
278 {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
279 {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
280 {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
281 {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
282 }
283 zeroes := make([]byte, 57)
284 tests := []struct {
285 name string
286 msg Theme
287 want []byte
288 }{
289 {
290 name: "empty",
291 msg: Theme{},
292 want: zeroes,
293 },
294 {
295 name: "foreground only",
296 msg: Theme{
297 HasForeground: true,
298 Foreground: renderer.RGB{R: 10, G: 20, B: 30},
299 Background: renderer.RGB{R: 40, G: 50, B: 60},
300 },
301 want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...),
302 },
303 {
304 name: "light flag without known scheme",
305 msg: Theme{
306 Light: true,
307 },
308 want: append([]byte{0x10}, zeroes[1:]...),
309 },
310 {
311 name: "full palette",
312 msg: Theme{
313 HasForeground: true,
314 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
315 HasBackground: true,
316 Background: renderer.RGB{R: 4, G: 5, B: 6},
317 TrueColor: true,
318 SchemeKnown: true,
319 PaletteKnown: 0x8001,
320 Palette: palette,
321 },
322 want: []byte{
323 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
324 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x17, 0x18,
325 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
326 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
327 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
328 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
329 0x3d, 0x3e, 0x3f,
330 },
331 },
332 }
333
334 for _, tt := range tests {
335 t.Run(tt.name, func(t *testing.T) {
336 got := MarshalTheme(tt.msg)
337 require.Len(t, got, 57)
338 require.Equal(t, tt.want, got)
339 back, err := UnmarshalTheme(got)
340 require.NoError(t, err)
341 require.Equal(t, tt.msg, back)
342 })
343 }
344
345 full := MarshalTheme(tests[len(tests)-1].msg)
346 assertAllPrefixesFail(t, full, UnmarshalTheme)
347 assertTrailingGarbageFails(t, full, UnmarshalTheme)
348}
349
350func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) {
351 // A generation clear retains defaults/capabilities but has no palette bits.
352 // This literal payload locks the existing 57-byte Theme layout while the
353 // full definitive-palette golden above locks the final publication.
354 cleared := Theme{
355 HasForeground: true,
356 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
357 HasBackground: true,
358 Background: renderer.RGB{R: 4, G: 5, B: 6},
359 TrueColor: true,
360 SchemeKnown: true,
361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))
364 require.Equal(t, uint16(29), ProtocolVersion)
365}
366
367func TestResizeGoldenAndRoundTrip(t *testing.T) {
368 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
369 want := []byte{0x00, 0x64, 0x00, 0x28}
370
371 got, err := MarshalResize(msg)
372 if err != nil {
373 t.Fatalf("MarshalResize() error = %v", err)
374 }
375 if !bytes.Equal(got, want) {
376 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
377 }
378 back, err := UnmarshalResize(got)
379 if err != nil {
380 t.Fatalf("UnmarshalResize() error = %v", err)
381 }
382 if !reflect.DeepEqual(back, msg) {
383 t.Fatalf("round trip = %#v, want %#v", back, msg)
384 }
385
386 assertAllPrefixesFail(t, got, UnmarshalResize)
387 assertTrailingGarbageFails(t, got, UnmarshalResize)
388}
389
390func TestDetachGoldenAndRoundTrip(t *testing.T) {
391 got := MarshalDetach(Detach{})
392 if len(got) != 0 {
393 t.Fatalf("MarshalDetach() = %#v, want empty", got)
394 }
395 back, err := UnmarshalDetach(got)
396 if err != nil {
397 t.Fatalf("UnmarshalDetach() error = %v", err)
398 }
399 if back != (Detach{}) {
400 t.Fatalf("round trip = %#v, want %#v", back, Detach{})
401 }
402 assertTrailingGarbageFails(t, got, UnmarshalDetach)
403}
404
405func TestPingGoldenAndRoundTrip(t *testing.T) {
406 got := MarshalPing(Ping{})
407 if len(got) != 0 {
408 t.Fatalf("MarshalPing() = %#v, want empty", got)
409 }
410 back, err := UnmarshalPing(got)
411 if err != nil {
412 t.Fatalf("UnmarshalPing() error = %v", err)
413 }
414 if back != (Ping{}) {
415 t.Fatalf("round trip = %#v, want %#v", back, Ping{})
416 }
417 assertTrailingGarbageFails(t, got, UnmarshalPing)
418}
419
420func TestPongGoldenAndRoundTrip(t *testing.T) {
421 got := MarshalPong(Pong{})
422 if len(got) != 0 {
423 t.Fatalf("MarshalPong() = %#v, want empty", got)
424 }
425 back, err := UnmarshalPong(got)
426 if err != nil {
427 t.Fatalf("UnmarshalPong() error = %v", err)
428 }
429 if back != (Pong{}) {
430 t.Fatalf("round trip = %#v, want %#v", back, Pong{})
431 }
432 assertTrailingGarbageFails(t, got, UnmarshalPong)
433}
434
435func TestMarshalWelcomeRejectsInvalidCommittedIdentity(t *testing.T) {
436 got := MarshalWelcome(Welcome{
437 SessionName: "work",
438 CommittedIdentity: &CommittedRouteIdentity{
439 Target: ExactSessionTarget{SessionName: "different"},
440 },
441 })
442 require.Nil(t, got)
443}
444
445func TestMarshalWelcomeRejectsCommittedIdentityMetadataMismatch(t *testing.T) {
446 base := Welcome{
447 SessionID: "session",
448 SessionName: "work",
449 Ephemeral: true,
450 CommittedIdentity: &CommittedRouteIdentity{
451 Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"},
452 Ephemeral: true,
453 },
454 }
455 for name, mutate := range map[string]func(*Welcome){
456 "session name": func(w *Welcome) { w.SessionName = "other" },
457 "ephemeral": func(w *Welcome) { w.Ephemeral = false },
458 } {
459 t.Run(name, func(t *testing.T) {
460 got := base
461 mutate(&got)
462 require.Nil(t, MarshalWelcome(got))
463 })
464 }
465 require.NotNil(t, MarshalWelcome(base))
466}
467
468func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
469 tests := []struct {
470 name string
471 msg Welcome
472 want []byte
473 }{
474 {
475 name: "ephemeral",
476 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
477 want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05, 0x00},
478 },
479 {
480 name: "non-ephemeral empty name",
481 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
482 want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
483 },
484 }
485
486 for _, tt := range tests {
487 t.Run(tt.name, func(t *testing.T) {
488 got := MarshalWelcome(tt.msg)
489 if !bytes.Equal(got, tt.want) {
490 t.Fatalf("MarshalWelcome() = %#v, want %#v", got, tt.want)
491 }
492 back, err := UnmarshalWelcome(got)
493 if err != nil {
494 t.Fatalf("UnmarshalWelcome() error = %v", err)
495 }
496 if !reflect.DeepEqual(back, tt.msg) {
497 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
498 }
499 })
500 }
501
502 full := MarshalWelcome(tests[0].msg)
503 assertAllPrefixesFail(t, full, UnmarshalWelcome)
504 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
505}
506
507func TestCommandErrorCodes(t *testing.T) {
508 require.Equal(t, uint16(6), ErrUnknownCommand)
509 require.Equal(t, uint16(7), ErrNotScriptable)
510 require.Equal(t, uint16(8), ErrInvalidCommandArgs)
511 require.Equal(t, uint16(9), ErrNoSuchTarget)
512 require.Equal(t, uint16(10), ErrAmbiguousTarget)
513}
514
515func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
516 tests := []struct {
517 name string
518 msg CommandRequest
519 want []byte
520 }{
521 {
522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{
525 0x00, 0x1d,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached
528 0x00, // self
529 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
530 0x00, 0x00,
531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
532 0x00,
533 },
534 },
535 {
536 name: "full",
537 msg: CommandRequest{
538 Version: ProtocolVersion,
539 Self: true,
540 Slug: "toast",
541 Args: []string{"-l", "warn", "tests KO"},
542 TargetSession: "dev",
543 TargetTab: "t_abc",
544 TargetPane: "p_def",
545 JSON: true,
546 },
547 want: []byte{
548 0x00, 0x1d,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached
551 0x01, // self
552 0x00, 0x05, 't', 'o', 'a', 's', 't',
553 0x00, 0x03,
554 0x00, 0x00, 0x00, 0x02, '-', 'l',
555 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
556 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
557 0x00, 0x03, 'd', 'e', 'v',
558 0x00, 0x05, 't', '_', 'a', 'b', 'c',
559 0x00, 0x05, 'p', '_', 'd', 'e', 'f',
560 0x01,
561 },
562 },
563 }
564
565 for _, tt := range tests {
566 t.Run(tt.name, func(t *testing.T) {
567 got, err := MarshalCommandRequest(tt.msg)
568 require.NoError(t, err)
569 require.Equal(t, tt.want, got)
570 back, err := UnmarshalCommandRequest(got)
571 require.NoError(t, err)
572 require.Equal(t, tt.msg, back)
573 version, ok := PeekCommandVersion(got)
574 require.True(t, ok)
575 require.Equal(t, tt.msg.Version, version)
576 assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
577 assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
578 })
579 }
580
581 if _, ok := PeekCommandVersion([]byte{0x00}); ok {
582 t.Fatal("PeekCommandVersion accepted a one-byte payload")
583 }
584}
585
586func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
587 payload, err := MarshalCommandRequest(CommandRequest{
588 Version: ProtocolVersion,
589 Slug: "toast",
590 Args: make([]string, math.MaxUint16+1),
591 })
592
593 require.Nil(t, payload)
594 require.ErrorIs(t, err, ErrTooManyCommandArgs)
595}
596
597func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
598 payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
599 require.NoError(t, err)
600 argCountOffset := 2 + 8 + 1 + 1 + 2 + len("toast")
601 payload[argCountOffset] = 0xff
602 payload[argCountOffset+1] = 0xff
603
604 if _, err := UnmarshalCommandRequest(payload); err == nil {
605 t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
606 }
607}
608
609func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
610 tests := []struct {
611 name string
612 msg CommandResult
613 want []byte
614 }{
615 {
616 name: "error",
617 msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
618 want: []byte{
619 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
620 0x00,
621 0x00, 0x09,
622 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
623 0x00, 0x00, 0x00, 0x00,
624 },
625 },
626 {
627 name: "success with output",
628 msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
629 want: []byte{
630 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
631 0x01,
632 0x00, 0x00,
633 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
634 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
635 },
636 },
637 }
638
639 for _, tt := range tests {
640 t.Run(tt.name, func(t *testing.T) {
641 got := MarshalCommandResult(tt.msg)
642 require.Equal(t, tt.want, got)
643 back, err := UnmarshalCommandResult(got)
644 require.NoError(t, err)
645 require.Equal(t, tt.msg, back)
646 assertAllPrefixesFail(t, got, UnmarshalCommandResult)
647 assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
648 })
649 }
650}
651
652func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
653 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
654 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
655
656 got := MarshalErrorMsg(msg)
657 if !bytes.Equal(got, want) {
658 t.Fatalf("MarshalErrorMsg() = %#v, want %#v", got, want)
659 }
660 back, err := UnmarshalErrorMsg(got)
661 if err != nil {
662 t.Fatalf("UnmarshalErrorMsg() error = %v", err)
663 }
664 if !reflect.DeepEqual(back, msg) {
665 t.Fatalf("round trip = %#v, want %#v", back, msg)
666 }
667
668 assertAllPrefixesFail(t, got, UnmarshalErrorMsg)
669 assertTrailingGarbageFails(t, got, UnmarshalErrorMsg)
670}
671
672func TestOutputGoldenAndRoundTrip(t *testing.T) {
673 msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}
674 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
675
676 got, err := MarshalOutput(msg)
677 if err != nil {
678 t.Fatalf("MarshalOutput() error = %v", err)
679 }
680 if !bytes.Equal(got, want) {
681 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
682 }
683 back, err := UnmarshalOutput(got)
684 if err != nil {
685 t.Fatalf("UnmarshalOutput() error = %v", err)
686 }
687 if !reflect.DeepEqual(back, msg) {
688 t.Fatalf("round trip = %#v, want %#v", back, msg)
689 }
690
691 assertAllPrefixesFail(t, got, UnmarshalOutput)
692 assertTrailingGarbageFails(t, got, UnmarshalOutput)
693}
694
695func TestAckGoldenAndRoundTrip(t *testing.T) {
696 msg := Ack{Epoch: 1, State: 0x0102030405060708}
697 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
698
699 got, err := MarshalAck(msg)
700 if err != nil {
701 t.Fatalf("MarshalAck() error = %v", err)
702 }
703 if !bytes.Equal(got, want) {
704 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
705 }
706 back, err := UnmarshalAck(got)
707 if err != nil {
708 t.Fatalf("UnmarshalAck() error = %v", err)
709 }
710 if back != msg {
711 t.Fatalf("round trip = %#v, want %#v", back, msg)
712 }
713
714 assertAllPrefixesFail(t, got, UnmarshalAck)
715 assertTrailingGarbageFails(t, got, UnmarshalAck)
716}
717
718func TestDetachedGoldenAndRoundTrip(t *testing.T) {
719 msg := Detached{Reason: ReasonReplaced}
720 want := []byte{0x03}
721
722 got := MarshalDetached(msg)
723 if !bytes.Equal(got, want) {
724 t.Fatalf("MarshalDetached() = %#v, want %#v", got, want)
725 }
726 back, err := UnmarshalDetached(got)
727 if err != nil {
728 t.Fatalf("UnmarshalDetached() error = %v", err)
729 }
730 if !reflect.DeepEqual(back, msg) {
731 t.Fatalf("round trip = %#v, want %#v", back, msg)
732 }
733
734 assertAllPrefixesFail(t, got, UnmarshalDetached)
735 assertTrailingGarbageFails(t, got, UnmarshalDetached)
736}
737
738func TestClientNoticeGoldenAndStrict(t *testing.T) {
739 tests := []struct {
740 name string
741 msg ClientNotice
742 want []byte
743 }{
744 {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}},
745 {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}},
746 {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}},
747 {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}},
748 }
749 for _, tt := range tests {
750 t.Run(tt.name, func(t *testing.T) {
751 got := MarshalClientNotice(tt.msg)
752 require.Equal(t, tt.want, got)
753 back, err := UnmarshalClientNotice(got)
754 require.NoError(t, err)
755 require.Equal(t, tt.msg, back)
756 assertAllPrefixesFail(t, got, UnmarshalClientNotice)
757 assertTrailingGarbageFails(t, got, UnmarshalClientNotice)
758 })
759 }
760 _, err := UnmarshalClientNotice([]byte{0xff})
761 require.Error(t, err, "unknown actions must not become display text")
762}
763
764func TestListGoldenAndRoundTrip(t *testing.T) {
765 got := MarshalList(List{})
766 if len(got) != 0 {
767 t.Fatalf("MarshalList() = %#v, want empty", got)
768 }
769 back, err := UnmarshalList(got)
770 if err != nil {
771 t.Fatalf("UnmarshalList() error = %v", err)
772 }
773 if back != (List{}) {
774 t.Fatalf("round trip = %#v, want %#v", back, List{})
775 }
776 assertTrailingGarbageFails(t, got, UnmarshalList)
777}
778
779func TestKillGoldenAndRoundTrip(t *testing.T) {
780 tests := []struct {
781 name string
782 msg Kill
783 want []byte
784 }{
785 {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
786 {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
787 {name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
788 }
789
790 for _, tt := range tests {
791 t.Run(tt.name, func(t *testing.T) {
792 got := MarshalKill(tt.msg)
793 if !bytes.Equal(got, tt.want) {
794 t.Fatalf("MarshalKill() = %#v, want %#v", got, tt.want)
795 }
796 back, err := UnmarshalKill(got)
797 if err != nil {
798 t.Fatalf("UnmarshalKill() error = %v", err)
799 }
800 if !reflect.DeepEqual(back, tt.msg) {
801 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
802 }
803 })
804 }
805
806 legacy := MarshalKill(tests[0].msg)
807 back, err := UnmarshalKill(legacy)
808 if err != nil {
809 t.Fatalf("UnmarshalKill(legacy) error = %v", err)
810 }
811 if !reflect.DeepEqual(back, Kill{Name: "main"}) {
812 t.Fatalf("legacy round trip = %#v, want named kill", back)
813 }
814 assertAllPrefixesFail(t, legacy, UnmarshalKill)
815 assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
816}
817
818func TestSessionInfoRecoveryState(t *testing.T) {
819 tests := []struct {
820 name string
821 state SessionState
822 want []byte
823 }{
824 {name: "up", state: SessionUp, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
825 {name: "down", state: SessionDown, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
826 {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
827 }
828 for _, tt := range tests {
829 t.Run(tt.name, func(t *testing.T) {
830 payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: tt.state}}})
831 require.Equal(t, tt.want, payload)
832 got, err := UnmarshalSessions(payload)
833 require.NoError(t, err)
834 require.Equal(t, tt.state, got.Sessions[0].State)
835 assertAllPrefixesFail(t, payload, UnmarshalSessions)
836 assertTrailingGarbageFails(t, payload, UnmarshalSessions)
837 })
838 }
839 for _, state := range []byte{3, 255} {
840 payload := append([]byte(nil), tests[0].want...)
841 payload[len(payload)-1] = state
842 _, err := UnmarshalSessions(payload)
843 require.Error(t, err, "state %d", state)
844 }
845}
846
847func TestSessionsGoldenAndRoundTrip(t *testing.T) {
848 tests := []struct {
849 name string
850 msg Sessions
851 want []byte
852 }{
853 {
854 name: "empty",
855 msg: Sessions{},
856 want: []byte{0x00, 0x00},
857 },
858 {
859 name: "two",
860 msg: Sessions{Sessions: []SessionInfo{
861 {SessionID: "0", Name: "0", State: SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
862 {SessionID: "work", Name: "proj", State: SessionDown, Ephemeral: false, Tabs: 5, Attached: true},
863 }},
864 want: []byte{
865 0x00, 0x02,
866 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
867 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
868 },
869 },
870 }
871
872 for _, tt := range tests {
873 t.Run(tt.name, func(t *testing.T) {
874 got := MarshalSessions(tt.msg)
875 if !bytes.Equal(got, tt.want) {
876 t.Fatalf("MarshalSessions() = %#v, want %#v", got, tt.want)
877 }
878 back, err := UnmarshalSessions(got)
879 if err != nil {
880 t.Fatalf("UnmarshalSessions() error = %v", err)
881 }
882 // Normalize nil vs empty slice for comparison.
883 if len(back.Sessions) == 0 && len(tt.msg.Sessions) == 0 {
884 return
885 }
886 if !reflect.DeepEqual(back, tt.msg) {
887 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
888 }
889 })
890 }
891
892 full := MarshalSessions(tests[1].msg)
893 assertAllPrefixesFail(t, full, UnmarshalSessions)
894 assertTrailingGarbageFails(t, full, UnmarshalSessions)
895}
896
897// TestMsgTypeConstantsDistinct guards the enumerations defined in this
898// package against accidental collisions (Intent, ErrorMsg code, Detached
899// reason are each independent small spaces, but duplicates within one
900// space would silently corrupt the protocol).
901func TestMsgTypeConstantsDistinct(t *testing.T) {
902 t.Run("intents", func(t *testing.T) {
903 vals := map[uint8]string{
904 IntentEphemeral: "IntentEphemeral",
905 IntentNew: "IntentNew",
906 IntentAttach: "IntentAttach",
907 IntentResume: "IntentResume",
908 }
909 if len(vals) != 4 {
910 t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
911 }
912 })
913
914 t.Run("error codes", func(t *testing.T) {
915 vals := map[uint16]string{
916 ErrVersionMismatch: "ErrVersionMismatch",
917 ErrNoSuchSession: "ErrNoSuchSession",
918 ErrNameTaken: "ErrNameTaken",
919 ErrServerShutdown: "ErrServerShutdown",
920 ErrInvalidSessionName: "ErrInvalidSessionName",
921 ErrInternal: "ErrInternal",
922 }
923 if len(vals) != 6 {
924 t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
925 }
926 })
927
928 t.Run("detached reasons", func(t *testing.T) {
929 vals := map[uint8]string{
930 ReasonDetach: "ReasonDetach",
931 ReasonSessionKilled: "ReasonSessionKilled",
932 ReasonServerShutdown: "ReasonServerShutdown",
933 ReasonReplaced: "ReasonReplaced",
934 }
935 if len(vals) != 4 {
936 t.Fatalf("expected 4 distinct detached reasons, got %d", len(vals))
937 }
938 })
939}
940
941func TestHelloOutputWindowByteExactValues(t *testing.T) {
942 for _, window := range []uint8{0, 1, 8} {
943 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
944 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
945 // The empty Hello has a fixed 1x1 size before the negotiated output window.
946 want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
947 want = appendNoNavigationTail(want)
948 want[1] = 14
949 want[30], want[32] = 1, 1
950 got := MarshalHello(hello)
951 requireBytesEqual(t, want, got)
952 back, err := UnmarshalHello(got)
953 require.NoError(t, err)
954 require.Equal(t, window, back.MaxOutputInFlight)
955 assertAllPrefixesFail(t, got, UnmarshalHello)
956 assertTrailingGarbageFails(t, got, UnmarshalHello)
957 })
958 }
959}
960
961func requireBytesEqual(t *testing.T, want, got []byte) {
962 t.Helper()
963 if !bytes.Equal(want, got) {
964 t.Fatalf("bytes = %#v, want %#v", got, want)
965 }
966}
967func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
968 tests := []struct {
969 name string
970 msg CommandRequest
971 want []byte
972 }{
973 {
974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{
977 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
981 },
982 },
983 {
984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{
987 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },
991 },
992 }
993 for _, tt := range tests {
994 t.Run(tt.name, func(t *testing.T) {
995 got, err := MarshalCommandRequest(tt.msg)
996 require.NoError(t, err)
997 require.Equal(t, tt.want, got)
998 back, err := UnmarshalCommandRequest(got)
999 require.NoError(t, err)
1000 require.Equal(t, tt.msg, back)
1001 assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
1002 assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
1003 })
1004 }
1005
1006 payload, err := MarshalCommandRequest(tests[0].msg)
1007 require.NoError(t, err)
1008 for _, offset := range []int{10, 11, len(payload) - 1} {
1009 malformed := append([]byte(nil), payload...)
1010 malformed[offset] = 2
1011 _, err := UnmarshalCommandRequest(malformed)
1012 require.Error(t, err)
1013 }
1014}
1015
1016func TestCommandResultCorrelationGoldenAndStrict(t *testing.T) {
1017 msg := CommandResult{RequestID: 0x0102030405060708, OK: true, Code: 7, Text: "ok", Output: "result"}
1018 want := []byte{
1019 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
1020 0x01, 0x00, 0x07, 0x00, 0x02, 'o', 'k', 0x00, 0x00, 0x00, 0x06, 'r', 'e', 's', 'u', 'l', 't',
1021 }
1022 got := MarshalCommandResult(msg)
1023 require.Equal(t, want, got)
1024 back, err := UnmarshalCommandResult(got)
1025 require.NoError(t, err)
1026 require.Equal(t, msg, back)
1027 assertAllPrefixesFail(t, got, UnmarshalCommandResult)
1028 assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
1029
1030 malformed := append([]byte(nil), got...)
1031 malformed[8] = 2
1032 _, err = UnmarshalCommandResult(malformed)
1033 require.Error(t, err)
1034}
1035
1036func TestOutputResetRequestStrict(t *testing.T) {
1037 got := MarshalOutputResetRequest(OutputResetRequest{})
1038 require.Empty(t, got)
1039 back, err := UnmarshalOutputResetRequest(got)
1040 require.NoError(t, err)
1041 require.Equal(t, OutputResetRequest{}, back)
1042 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
1043}