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