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/protocol"
14 "github.com/bnema/vev/internal/protocol/wire"
15 "github.com/stretchr/testify/require"
16)
17
18func protoTestTarget() domain.RemoteSessionTarget {
19 return domain.RemoteSessionTarget{
20 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
21 SessionName: "work", LiveTabID: "tab-1",
22 }
23}
24
25func protoTestContext() *protocol.ViewContext {
26 lifecycle := domain.SessionLifecycleID{1}
27 return &protocol.ViewContext{
28 Publication: 3,
29 Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}},
30 TabID: "tab-1",
31 FocusedPaneID: "pane-1",
32 }
33}
34
35// TestProtoClientRoundTrips exercises every client converter pair through
36// scan + generated marshal + generated unmarshal + semantic validation.
37func TestProtoClientRoundTrips(t *testing.T) {
38 target := protoTestTarget()
39 messages := []protocol.ClientMessage{
40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
42 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
43 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
44 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
45 protocol.Detach{},
46 protocol.Ping{},
47 protocol.List{},
48 protocol.Kill{RequestID: 1, Name: "work"},
49 protocol.Theme{TrueColor: true},
50 protocol.Ack{Epoch: 1, State: 1},
51 protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
52 protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
53 protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
54 protocol.OutputResetRequest{},
55 protocol.UIFence{ActionID: 7},
56 protocol.SelectTab{TabID: "tab-2"},
57 protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond},
58 protocol.RouteAttentionSubscription{},
59 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
60 protocol.RecentRouteSnapshot{},
61 protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
62 protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable},
63 protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot},
64 protocol.NavigationInventoryPublication{InteractionGeneration: 1, PublicationGeneration: 1},
65 protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity},
66 protocol.PickerClose{InteractionID: 1},
67 protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "k", Action: protocol.PickerActionNavigate},
68 }
69 for _, message := range messages {
70 t.Run(protoMessageName(message), func(t *testing.T) {
71 envelope, err := encodeProtoClient(message)
72 require.NoError(t, err)
73 raw, err := proto.Marshal(envelope)
74 require.NoError(t, err)
75 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
76 decoded := &wire.ClientEnvelope{}
77 require.NoError(t, proto.Unmarshal(raw, decoded))
78 got, err := decodeProtoClient(decoded)
79 require.NoError(t, err)
80 require.Equal(t, message, got)
81 })
82 }
83}
84
85// TestProtoServerRoundTrips exercises every server converter pair.
86func TestProtoServerRoundTrips(t *testing.T) {
87 lifecycle := domain.SessionLifecycleID{1}
88 exact := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
89 context := protoTestContext()
90 messages := []protocol.ServerMessage{
91 protocol.Welcome{SessionID: "session"},
92 protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"},
93 protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context},
94 protocol.Detached{Reason: protocol.ReasonDetach},
95 protocol.Pong{},
96 protocol.Sessions{},
97 protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
98 protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
99 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"}}},
100 protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
101 protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable},
102 protocol.CommittedRouteIdentity{Target: exact},
103 protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1},
104 protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"},
105 protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable},
106 protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"},
107 protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: exact},
108 protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable},
109 protocol.UIReceipt{ActionID: 1, Outcome: protocol.UIReceiptUnavailable},
110 protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *context},
111 protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK},
112 protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true},
113 protocol.NavigationInventorySelection{CauseActionID: 1, InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"},
114 protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, Title: "pick"},
115 protocol.PickerSnapshot{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: protocol.PickerProjection{}, Grouped: protocol.PickerProjection{}},
116 protocol.PickerClosed{InteractionID: 1},
117 protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate},
118 protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision},
119 }
120 for _, message := range messages {
121 t.Run(protoMessageName(message), func(t *testing.T) {
122 envelope, err := encodeProtoServer(message)
123 require.NoError(t, err)
124 raw, err := proto.Marshal(envelope)
125 require.NoError(t, err)
126 require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
127 decoded := &wire.ServerEnvelope{}
128 require.NoError(t, proto.Unmarshal(raw, decoded))
129 got, err := decodeProtoServer(decoded)
130 require.NoError(t, err)
131 require.Equal(t, message, got)
132 })
133 }
134}
135
136func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) {
137 lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
138 for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} {
139 _, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true})
140 require.Error(t, err)
141 }
142}
143
144func TestSessionAttachTargetTabIndexPresence(t *testing.T) {
145 lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
146
147 zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true})
148 require.NoError(t, err)
149 require.Equal(t, int32(0), zero.TabIndex)
150
151 absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true})
152 require.NoError(t, err)
153 require.Equal(t, protocol.NoTabIndex, absent.TabIndex)
154
155 encoded, err := sessionAttachTargetToWire(zero)
156 require.NoError(t, err)
157 require.Zero(t, encoded.GetTabIndex())
158}
159
160// TestProtoWrongDirectionRejects proves a server payload in a client
161// envelope (and vice versa) fails before any mutation.
162func TestProtoWrongDirectionRejects(t *testing.T) {
163 // Direction is enforced by Go types at encode time (a server-only
164 // message does not implement protocol.ClientMessage, so misuse does
165 // not compile) and by exhaustive decode switches at decode time.
166 // Here: an empty client envelope and an unknown wrapper fail closed.
167 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
168 require.ErrorIs(t, err, ErrWrongDirection)
169 _, err = decodeProtoClient(nil)
170 require.ErrorIs(t, err, ErrInvalidMessage)
171 _, err = decodeProtoServer(nil)
172 require.ErrorIs(t, err, ErrInvalidMessage)
173}
174
175// TestProtoOutputCompressionRoundTrip keeps the frozen policy observable:
176// large full snapshots compress, small/incremental stay raw, and zip bombs
177// fail closed.
178func TestProtoOutputCompressionRoundTrip(t *testing.T) {
179 context := protoTestContext()
180 full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
181 Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
182 converted, err := outputToWire(full)
183 require.NoError(t, err)
184 require.Equal(t, uint32(1), converted.GetEncoding())
185 back, err := outputFromWire(converted)
186 require.NoError(t, err)
187 require.Equal(t, full.Data, back.Data)
188
189 small := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: []byte("small")}
190 converted, err = outputToWire(small)
191 require.NoError(t, err)
192 require.Equal(t, uint32(0), converted.GetEncoding())
193
194 incremental := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: bytes.Repeat([]byte("x"), 2048)}
195 converted, err = outputToWire(incremental)
196 require.NoError(t, err)
197 require.Equal(t, uint32(0), converted.GetEncoding())
198
199 bomb := proto.Clone(converted).(*wire.Output)
200 bomb.UncompressedLength = uint64(protocol.MaxOutputDataLen) + 1
201 _, err = outputFromWire(bomb)
202 require.Error(t, err)
203}
204
205// TestProtoOutputRejectsCompressedTrailingBytes restores the legacy
206// trailing-data guard: bytes appended after the zlib stream end must
207// fail closed (the zlib reader stops at stream end and would otherwise
208// swallow them).
209func TestProtoOutputRejectsCompressedTrailingBytes(t *testing.T) {
210 context := protoTestContext()
211 full := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 120, Rows: 40}, Context: context,
212 Data: bytes.Repeat([]byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n"), 128)}
213 converted, err := outputToWire(full)
214 require.NoError(t, err)
215 require.Equal(t, uint32(1), converted.GetEncoding())
216
217 trailing := proto.Clone(converted).(*wire.Output)
218 trailing.Data = append(append([]byte(nil), converted.GetData()...), []byte("trailing-garbage")...)
219 _, err = outputFromWire(trailing)
220 require.Error(t, err)
221}
222
223// TestProtoScanNegatives fuzzes the scanner contract: unknown fields,
224// duplicate singulars, empty envelopes, truncation, trailing garbage.
225func TestProtoScanNegatives(t *testing.T) {
226 valid, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Ping{Ping: &wire.Ping{}}})
227 require.NoError(t, err)
228 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, valid))
229
230 truncated := valid[:len(valid)-1]
231 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, truncated))
232 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(valid, 0xFF)))
233 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, nil))
234 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}))
235 require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, []byte{}), wire.ErrScanEmpty)
236
237 // Unknown top-level field 99 (varint).
238 unknown := append([]byte(nil), valid...)
239 unknown = protowire.AppendTag(unknown, 99, protowire.VarintType)
240 unknown = protowire.AppendVarint(unknown, 1)
241 require.ErrorIs(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, unknown), wire.ErrScanUnknown)
242
243 // Duplicate top-level variant: two ping occurrences.
244 duplicate := append([]byte(nil), valid...)
245 duplicate = append(duplicate, valid...)
246 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
247
248 // Concatenated envelopes: generated unmarshal alone takes last-wins,
249 // which is why the scanner must reject them first.
250 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, duplicate))
251 envelope := &wire.ClientEnvelope{}
252 require.NoError(t, proto.Unmarshal(duplicate, envelope))
253}
254
255// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
256// a malformed tab identity, a truncated prefix, and trailing garbage.
257func TestProtoSelectTabWire(t *testing.T) {
258 envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
259 require.NoError(t, err)
260 raw, err := proto.Marshal(envelope)
261 require.NoError(t, err)
262 // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
263 require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
264
265 tests := []struct {
266 name string
267 raw []byte
268 wantErr bool
269 }{
270 {name: "exact", raw: raw},
271 {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
272 {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
273 }
274 for _, tt := range tests {
275 t.Run(tt.name, func(t *testing.T) {
276 err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
277 if tt.wantErr {
278 require.Error(t, err)
279 return
280 }
281 require.NoError(t, err)
282 decoded := &wire.ClientEnvelope{}
283 require.NoError(t, proto.Unmarshal(tt.raw, decoded))
284 got, err := decodeProtoClient(decoded)
285 require.NoError(t, err)
286 require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
287 })
288 }
289
290 _, err = encodeProtoClient(protocol.SelectTab{})
291 require.Error(t, err, "an empty tab identity is never encoded")
292 _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
293 require.Error(t, err, "an empty tab identity is never decoded")
294}
295
296// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
297// refuses unknown focus, a truncated prefix, and trailing garbage.
298func TestProtoTerminalFocusWire(t *testing.T) {
299 tests := []struct {
300 name string
301 focus domain.TerminalFocus
302 want []byte
303 }{
304 // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
305 {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
306 {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
307 }
308 for _, tt := range tests {
309 t.Run(tt.name, func(t *testing.T) {
310 message := protocol.TerminalFocus{Focus: tt.focus}
311 envelope, err := encodeProtoClient(message)
312 require.NoError(t, err)
313 raw, err := proto.Marshal(envelope)
314 require.NoError(t, err)
315 require.Equal(t, tt.want, raw)
316
317 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
318 require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
319 require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
320 decoded := &wire.ClientEnvelope{}
321 require.NoError(t, proto.Unmarshal(raw, decoded))
322 got, err := decodeProtoClient(decoded)
323 require.NoError(t, err)
324 require.Equal(t, message, got)
325 })
326 }
327
328 invalid := []struct {
329 name string
330 focus uint32
331 }{
332 {name: "unknown is never reported", focus: 0},
333 {name: "out of taxonomy", focus: 3},
334 {name: "out of range", focus: 256},
335 }
336 for _, tt := range invalid {
337 t.Run(tt.name, func(t *testing.T) {
338 _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
339 require.Error(t, err)
340 })
341 }
342 _, err := encodeProtoClient(protocol.TerminalFocus{})
343 require.Error(t, err, "unknown focus is never encoded")
344}
345
346func protoMessageName(message any) string {
347 return protoShortName(message)
348}
349
350func protoShortName(message any) string {
351 if message == nil {
352 return "nil"
353 }
354 return reflect.TypeOf(message).Name()
355}