1package sessionwire
2
3import (
4 "bytes"
5 "reflect"
6 "testing"
7 "time"
8
9 "google.golang.org/protobuf/encoding/protowire"
10 "google.golang.org/protobuf/proto"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/domain/terminalcap"
14 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/wire"
16 "github.com/stretchr/testify/require"
17)
18
19func protoTestTarget() domain.RemoteSessionTarget {
20 return domain.RemoteSessionTarget{
21 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
22 SessionName: "work", LiveTabID: "tab-1",
23 }
24}
25
26func protoTestContext() *protocol.ViewContext {
27 lifecycle := domain.SessionLifecycleID{1}
28 return &protocol.ViewContext{
29 Publication: 3,
30 Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}},
31 TabID: "tab-1",
32 FocusedPaneID: "pane-1",
33 }
34}
35
36// TestProtoClientRoundTrips exercises every client converter pair through
37// scan + generated marshal + generated unmarshal + semantic validation.
38func TestProtoClientRoundTrips(t *testing.T) {
39 target := protoTestTarget()
40 messages := []protocol.ClientMessage{
41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
42 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
43 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
44 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
45 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
46 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
47 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
48 protocol.Detach{},
49 protocol.Ping{},
50 protocol.List{},
51 protocol.Kill{RequestID: 1, Name: "work"},
52 protocol.Theme{SchemeKnown: true},
53 protocol.Ack{Epoch: 1, State: 1},
54 protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
55 protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
56 protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
57 protocol.OutputResetRequest{},
58 protocol.UIFence{ActionID: 7},
59 protocol.SelectTab{TabID: "tab-2"},
60 protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond},
61 protocol.RouteAttentionSubscription{},
62 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
63 protocol.RecentRouteSnapshot{},
64 protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
65 protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable},
66 protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot},
67 protocol.NavigationInventoryPublication{InteractionGeneration: 1, PublicationGeneration: 1},
68 protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity},
69 protocol.PickerClose{InteractionID: 1},
70 protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "k", Action: protocol.PickerActionNavigate},
71 }
72 for _, message := range messages {
73 t.Run(protoMessageName(message), func(t *testing.T) {
74 envelope, err := encodeProtoClient(message)
75 require.NoError(t, err)
76 raw, err := proto.Marshal(envelope)
77 require.NoError(t, err)
78 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
79 decoded := &wire.ClientEnvelope{}
80 require.NoError(t, proto.Unmarshal(raw, decoded))
81 got, err := decodeProtoClient(decoded)
82 require.NoError(t, err)
83 require.Equal(t, message, got)
84 })
85 }
86}
87
88// TestProtoServerRoundTrips exercises every server converter pair.
89func TestProtoServerRoundTrips(t *testing.T) {
90 lifecycle := domain.SessionLifecycleID{1}
91 exact := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
92 context := protoTestContext()
93 messages := []protocol.ServerMessage{
94 protocol.Welcome{SessionID: "session"},
95 protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"},
96 protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context},
97 protocol.Detached{Reason: protocol.ReasonDetach},
98 protocol.Pong{},
99 protocol.Sessions{},
100 protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
101 protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
102 protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}},
103 protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
104 protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable},
105 protocol.CommittedRouteIdentity{Target: exact},
106 protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1},
107 protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"},
108 protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
109 protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"},
110 protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: exact},
111 protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable},
112 protocol.UIReceipt{ActionID: 1, Outcome: protocol.UIReceiptUnavailable},
113 protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *context},
114 protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK},
115 protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true},
116 protocol.NavigationInventorySelection{CauseActionID: 1, InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"},
117 protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, Title: "pick"},
118 protocol.PickerSnapshot{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: protocol.PickerProjection{}, Grouped: protocol.PickerProjection{}},
119 protocol.PickerClosed{InteractionID: 1},
120 protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate},
121 protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision},
122 }
123 for _, message := range messages {
124 t.Run(protoMessageName(message), func(t *testing.T) {
125 envelope, err := encodeProtoServer(message)
126 require.NoError(t, err)
127 raw, err := proto.Marshal(envelope)
128 require.NoError(t, err)
129 require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
130 decoded := &wire.ServerEnvelope{}
131 require.NoError(t, proto.Unmarshal(raw, decoded))
132 got, err := decodeProtoServer(decoded)
133 require.NoError(t, err)
134 require.Equal(t, message, got)
135 })
136 }
137}
138
139func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) {
140 lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
141 for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} {
142 _, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true})
143 require.Error(t, err)
144 }
145}
146
147func TestSessionAttachTargetTabIndexPresence(t *testing.T) {
148 lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
149
150 zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true})
151 require.NoError(t, err)
152 require.Equal(t, int32(0), zero.TabIndex)
153
154 absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true})
155 require.NoError(t, err)
156 require.Equal(t, protocol.NoTabIndex, absent.TabIndex)
157
158 encoded, err := sessionAttachTargetToWire(zero)
159 require.NoError(t, err)
160 require.Zero(t, encoded.GetTabIndex())
161}
162
163// TestProtoWrongDirectionRejects proves a server payload in a client
164// envelope (and vice versa) fails before any mutation.
165func TestProtoWrongDirectionRejects(t *testing.T) {
166 // Direction is enforced by Go types at encode time (a server-only
167 // message does not implement protocol.ClientMessage, so misuse does
168 // not compile) and by exhaustive decode switches at decode time.
169 // Here: an empty client envelope and an unknown wrapper fail closed.
170 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
171 require.ErrorIs(t, err, ErrWrongDirection)
172 _, err = decodeProtoClient(nil)
173 require.ErrorIs(t, err, ErrInvalidMessage)
174 _, err = decodeProtoServer(nil)
175 require.ErrorIs(t, err, ErrInvalidMessage)
176}
177
178// TestProtoOutputCompressionRoundTrip keeps the frozen policy observable:
179// large full snapshots compress, small/incremental stay raw, and zip bombs
180// fail closed.
181func TestProtoOutputCompressionRoundTrip(t *testing.T) {
182 context := protoTestContext()
183 full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
184 Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
185 converted, err := outputToWire(full)
186 require.NoError(t, err)
187 require.Equal(t, uint32(1), converted.GetEncoding())
188 back, err := outputFromWire(converted)
189 require.NoError(t, err)
190 require.Equal(t, full.Data, back.Data)
191
192 small := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: []byte("small")}
193 converted, err = outputToWire(small)
194 require.NoError(t, err)
195 require.Equal(t, uint32(0), converted.GetEncoding())
196
197 incremental := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: bytes.Repeat([]byte("x"), 2048)}
198 converted, err = outputToWire(incremental)
199 require.NoError(t, err)
200 require.Equal(t, uint32(0), converted.GetEncoding())
201
202 bomb := proto.Clone(converted).(*wire.Output)
203 bomb.UncompressedLength = uint64(protocol.MaxOutputDataLen) + 1
204 _, err = outputFromWire(bomb)
205 require.Error(t, err)
206}
207
208// TestProtoOutputRejectsCompressedTrailingBytes restores the legacy
209// trailing-data guard: bytes appended after the zlib stream end must
210// fail closed (the zlib reader stops at stream end and would otherwise
211// swallow them).
212func TestProtoOutputRejectsCompressedTrailingBytes(t *testing.T) {
213 context := protoTestContext()
214 full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
215 Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
216 converted, err := outputToWire(full)
217 require.NoError(t, err)
218 require.Equal(t, uint32(1), converted.GetEncoding())
219
220 trailing := proto.Clone(converted).(*wire.Output)
221 trailing.Data = append(append([]byte(nil), converted.GetData()...), []byte("trailing-garbage")...)
222 _, err = outputFromWire(trailing)
223 require.Error(t, err)
224}
225
226// TestProtoScanNegatives fuzzes the scanner contract: unknown fields,
227// duplicate singulars, empty envelopes, truncation, trailing garbage.
228func TestProtoScanNegatives(t *testing.T) {
229 valid, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Ping{Ping: &wire.Ping{}}})
230 require.NoError(t, err)
231 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, valid))
232
233 truncated := valid[:len(valid)-1]
234 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, truncated))
235 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(valid, 0xFF)))
236 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, nil))
237 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}))
238 require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}), wire.ErrScanEmpty)
239
240 // Unknown top-level field 99 (varint).
241 unknown := append([]byte(nil), valid...)
242 unknown = protowire.AppendTag(unknown, 99, protowire.VarintType)
243 unknown = protowire.AppendVarint(unknown, 1)
244 require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, unknown), wire.ErrScanUnknown)
245
246 // Duplicate top-level variant: two ping occurrences.
247 duplicate := append([]byte(nil), valid...)
248 duplicate = append(duplicate, valid...)
249 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
250
251 // Concatenated envelopes: generated unmarshal alone takes last-wins,
252 // which is why the scanner must reject them first.
253 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
254 envelope := &wire.ClientEnvelope{}
255 require.NoError(t, proto.Unmarshal(duplicate, envelope))
256}
257
258// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
259// a malformed tab identity, a truncated prefix, and trailing garbage.
260func TestProtoSelectTabWire(t *testing.T) {
261 envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
262 require.NoError(t, err)
263 raw, err := proto.Marshal(envelope)
264 require.NoError(t, err)
265 // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
266 require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
267
268 tests := []struct {
269 name string
270 raw []byte
271 wantErr bool
272 }{
273 {name: "exact", raw: raw},
274 {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
275 {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
276 }
277 for _, tt := range tests {
278 t.Run(tt.name, func(t *testing.T) {
279 err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
280 if tt.wantErr {
281 require.Error(t, err)
282 return
283 }
284 require.NoError(t, err)
285 decoded := &wire.ClientEnvelope{}
286 require.NoError(t, proto.Unmarshal(tt.raw, decoded))
287 got, err := decodeProtoClient(decoded)
288 require.NoError(t, err)
289 require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
290 })
291 }
292
293 _, err = encodeProtoClient(protocol.SelectTab{})
294 require.Error(t, err, "an empty tab identity is never encoded")
295 _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
296 require.Error(t, err, "an empty tab identity is never decoded")
297}
298
299// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
300// refuses unknown focus, a truncated prefix, and trailing garbage.
301func TestProtoTerminalFocusWire(t *testing.T) {
302 tests := []struct {
303 name string
304 focus domain.TerminalFocus
305 want []byte
306 }{
307 // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
308 {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
309 {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
310 }
311 for _, tt := range tests {
312 t.Run(tt.name, func(t *testing.T) {
313 message := protocol.TerminalFocus{Focus: tt.focus}
314 envelope, err := encodeProtoClient(message)
315 require.NoError(t, err)
316 raw, err := proto.Marshal(envelope)
317 require.NoError(t, err)
318 require.Equal(t, tt.want, raw)
319
320 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
321 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
322 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
323 decoded := &wire.ClientEnvelope{}
324 require.NoError(t, proto.Unmarshal(raw, decoded))
325 got, err := decodeProtoClient(decoded)
326 require.NoError(t, err)
327 require.Equal(t, message, got)
328 })
329 }
330
331 invalid := []struct {
332 name string
333 focus uint32
334 }{
335 {name: "unknown is never reported", focus: 0},
336 {name: "out of taxonomy", focus: 3},
337 {name: "out of range", focus: 256},
338 }
339 for _, tt := range invalid {
340 t.Run(tt.name, func(t *testing.T) {
341 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
342 require.Error(t, err)
343 })
344 }
345 _, err := encodeProtoClient(protocol.TerminalFocus{})
346 require.Error(t, err, "unknown focus is never encoded")
347}
348
349// TestProtoModifierStateWire pins the ModifierState payload byte for byte and
350// refuses a truncated prefix and trailing garbage.
351func TestProtoModifierStateWire(t *testing.T) {
352 tests := []struct {
353 name string
354 ctrl bool
355 want []byte
356 }{
357 // field 34 (length-delimited) -> ModifierState{field 1 = ctrl}.
358 {name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}},
359 {name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}},
360 }
361 for _, tt := range tests {
362 t.Run(tt.name, func(t *testing.T) {
363 message := protocol.ModifierState{Ctrl: tt.ctrl}
364 envelope, err := encodeProtoClient(message)
365 require.NoError(t, err)
366 raw, err := proto.Marshal(envelope)
367 require.NoError(t, err)
368 require.Equal(t, tt.want, raw)
369
370 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
371 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
372 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
373 decoded := &wire.ClientEnvelope{}
374 require.NoError(t, proto.Unmarshal(raw, decoded))
375 got, err := decodeProtoClient(decoded)
376 require.NoError(t, err)
377 require.Equal(t, message, got)
378 })
379 }
380 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}})
381 require.Error(t, err, "a missing payload is never decoded")
382}
383
384func protoMessageName(message any) string {
385 return protoShortName(message)
386}
387
388func protoShortName(message any) string {
389 if message == nil {
390 return "nil"
391 }
392 return reflect.TypeOf(message).Name()
393}
394
395// TestProtoHelloColorWire pins the Hello color capability byte for byte for
396// every mode and source, and refuses truncated prefixes, trailing garbage,
397// and values outside the closed enums.
398func TestProtoHelloColorWire(t *testing.T) {
399 modes := []struct {
400 name string
401 mode terminalcap.ColorMode
402 code byte
403 }{
404 {name: "truecolor", mode: terminalcap.TrueColor, code: 1},
405 {name: "ansi256", mode: terminalcap.ANSI256, code: 2},
406 {name: "ansi16", mode: terminalcap.ANSI16, code: 3},
407 {name: "monochrome", mode: terminalcap.Monochrome, code: 4},
408 }
409 sources := []struct {
410 name string
411 source terminalcap.Source
412 code byte
413 }{
414 {name: "unknown", source: terminalcap.SourceUnknown, code: 0},
415 {name: "heuristic", source: terminalcap.SourceHeuristic, code: 1},
416 {name: "declared", source: terminalcap.SourceDeclared, code: 2},
417 {name: "forced", source: terminalcap.SourceForced, code: 3},
418 }
419 for _, m := range modes {
420 for _, src := range sources {
421 t.Run(m.name+"/"+src.name, func(t *testing.T) {
422 color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source}
423 // The minimal attach Hello: version, intent, empty client id, cols,
424 // rows, then color (field 26, length-delimited), preceded by the
425 // oneof tag for Hello (field 1).
426 colorBytes := []byte{0xD2, 0x01}
427 if src.code == 0 {
428 colorBytes = append(colorBytes, 0x02, 0x08, m.code)
429 } else {
430 colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code)
431 }
432 hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color}
433 envelope, err := encodeProtoClient(hello)
434 require.NoError(t, err)
435 raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope)
436 require.NoError(t, err)
437 require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw)
438 // ClientEnvelope tag 1 (Hello), then the Hello length.
439 require.Equal(t, byte(0x0A), raw[0])
440
441 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
442 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
443 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
444 decoded := &wire.ClientEnvelope{}
445 require.NoError(t, proto.Unmarshal(raw, decoded))
446 got, err := decodeProtoClient(decoded)
447 require.NoError(t, err)
448 require.Equal(t, hello, got)
449 })
450 }
451 }
452
453 t.Run("missing color is rejected", func(t *testing.T) {
454 message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24}
455 raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
456 require.NoError(t, err)
457 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid")
458 decoded := &wire.ClientEnvelope{}
459 require.NoError(t, proto.Unmarshal(raw, decoded))
460 _, err = decodeProtoClient(decoded)
461 require.Error(t, err)
462 })
463
464 t.Run("unspecified mode on the wire is rejected", func(t *testing.T) {
465 message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}}
466 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
467 require.Error(t, err)
468 })
469
470 t.Run("unknown enum values are rejected", func(t *testing.T) {
471 for _, color := range []*wire.ColorCapabilities{
472 {Mode: wire.ColorMode(5)},
473 {Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)},
474 } {
475 message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color}
476 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
477 require.Error(t, err)
478 }
479 })
480
481 t.Run("invalid semantic color is never encoded", func(t *testing.T) {
482 _, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
483 require.Error(t, err)
484 })
485}