1package sessionwire
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "google.golang.org/protobuf/encoding/protowire"
9 "google.golang.org/protobuf/proto"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/wire"
14 "github.com/stretchr/testify/require"
15)
16
17// overControlCap is comfortably above wire.ControlEnvelopeLimit but well
18// below wire.AbsoluteEnvelopeLimit, so a category-exempt envelope still
19// rides the negotiated ceiling while a control envelope is capped.
20const overControlCap = wire.ControlEnvelopeLimit + (1 << 20)
21
22// testOutputRaw builds an Output that never compresses (Full is false), so
23// its serialized envelope stays above the control cap.
24func testOutputRaw(data []byte) protocol.Output {
25 return protocol.Output{Epoch: 1, New: 0, Size: domain.Size{Cols: 80, Rows: 24}, Data: data}
26}
27
28func mustMarshalClientEnvelope(t *testing.T, envelope *wire.ClientEnvelope) []byte {
29 t.Helper()
30 raw, err := proto.Marshal(envelope)
31 require.NoError(t, err)
32 return raw
33}
34
35func mustMarshalServerEnvelope(t *testing.T, envelope *wire.ServerEnvelope) []byte {
36 t.Helper()
37 raw, err := proto.Marshal(envelope)
38 require.NoError(t, err)
39 return raw
40}
41
42// TestEnvelopeCategoryClassification proves category detection derives from
43// the serialized leading tag only: ImagePush/Output are exempt, every other
44// variant and every unknown, empty, or malformed payload stays control.
45func TestEnvelopeCategoryClassification(t *testing.T) {
46 imagePayload := mustMarshalClientEnvelope(t, &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ImagePush{ImagePush: &wire.ImagePush{Data: []byte("img")}}})
47 pingPayload := mustMarshalClientEnvelope(t, &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Ping{Ping: &wire.Ping{}}})
48 outputPayload := mustMarshalServerEnvelope(t, &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_Output{Output: &wire.Output{Data: []byte("out")}}})
49 errorPayload := mustMarshalServerEnvelope(t, &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_Error{Error: &wire.ErrorMsg{}}})
50 unknownField := protowire.AppendBytes(protowire.AppendTag(nil, 99, protowire.BytesType), []byte("x"))
51
52 clientCases := []struct {
53 name string
54 payload []byte
55 want envelopeCategory
56 }{
57 {"image push is exempt", imagePayload, categoryBulk},
58 {"control variant", pingPayload, categoryControl},
59 {"server envelope is control", outputPayload, categoryControl},
60 {"unknown field", unknownField, categoryControl},
61 {"empty payload", nil, categoryControl},
62 {"truncated tag", []byte{0xFF}, categoryControl},
63 {"non-length-delimited tag", protowire.AppendTag(nil, 1, protowire.VarintType), categoryControl},
64 }
65 for _, tt := range clientCases {
66 t.Run("client/"+tt.name, func(t *testing.T) {
67 require.Equal(t, tt.want, clientEnvelopeCategory(tt.payload))
68 })
69 }
70
71 serverCases := []struct {
72 name string
73 payload []byte
74 want envelopeCategory
75 }{
76 {"output is exempt", outputPayload, categoryOutput},
77 {"control variant", errorPayload, categoryControl},
78 {"client envelope is control", imagePayload, categoryControl},
79 {"unknown field", unknownField, categoryControl},
80 {"empty payload", nil, categoryControl},
81 {"truncated tag", []byte{0xFF}, categoryControl},
82 }
83 for _, tt := range serverCases {
84 t.Run("server/"+tt.name, func(t *testing.T) {
85 require.Equal(t, tt.want, serverEnvelopeCategory(tt.payload))
86 })
87 }
88}
89
90// TestClientSendCategoryEnvelopeCeilings proves client sends cap control
91// envelopes at wire.ControlEnvelopeLimit while ImagePush rides the negotiated
92// ceiling; a lower negotiated ceiling still wins for both categories.
93func TestClientSendCategoryEnvelopeCeilings(t *testing.T) {
94 inputOver := protocol.Input{InputSeq: 1, Data: bytes.Repeat([]byte{0xAB}, overControlCap)}
95 imageOver := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: bytes.Repeat([]byte{0xAB}, overControlCap)}
96 smallControl := protocol.Input{InputSeq: 1, Data: bytes.Repeat([]byte{0x5A}, 4096)}
97 smallLen := len(mustEncodeClient(t, smallControl))
98
99 tests := []struct {
100 name string
101 message protocol.ClientMessage
102 ceilings protoCeilings
103 wantErr error
104 }{
105 {
106 name: "input rides absolute negotiation",
107 message: inputOver,
108 ceilings: defaultProtoCeilings(),
109 },
110 {
111 name: "image rides absolute negotiation",
112 message: imageOver,
113 ceilings: defaultProtoCeilings(),
114 },
115 {
116 name: "image respects lower negotiated ceiling",
117 message: imageOver,
118 ceilings: protoCeilings{maxReceiveEnvelopeBytes: wire.ControlEnvelopeLimit, outputDataLimit: uint64(protocol.MaxOutputDataLen)},
119 wantErr: wire.ErrScanLength,
120 },
121 {
122 name: "control fits negotiated lower ceiling",
123 message: smallControl,
124 ceilings: protoCeilings{maxReceiveEnvelopeBytes: uint64(smallLen), outputDataLimit: uint64(protocol.MaxOutputDataLen)},
125 },
126 {
127 name: "control above negotiated lower ceiling",
128 message: smallControl,
129 ceilings: protoCeilings{maxReceiveEnvelopeBytes: uint64(smallLen - 1), outputDataLimit: uint64(protocol.MaxOutputDataLen)},
130 wantErr: wire.ErrScanLength,
131 },
132 }
133 for _, tt := range tests {
134 t.Run(tt.name, func(t *testing.T) {
135 raw := &scriptedTransport{}
136 conn := &clientConnection{raw: raw, ceilings: tt.ceilings}
137 conn.preambleOnce.Do(func() {})
138 err := conn.SendClient(tt.message)
139 if tt.wantErr != nil {
140 require.ErrorIs(t, err, tt.wantErr)
141 require.Zero(t, raw.sentLen())
142 return
143 }
144 require.NoError(t, err)
145 require.Equal(t, 1, raw.sentLen())
146 })
147 }
148}
149
150// TestServerSendCategoryEnvelopeCeilings proves server sends cap control
151// envelopes at wire.ControlEnvelopeLimit while Output rides the negotiated
152// ceiling; a lower negotiated ceiling still wins for both categories.
153func TestServerSendCategoryEnvelopeCeilings(t *testing.T) {
154 controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}}
155 outputOver := testOutputRaw(bytes.Repeat([]byte{0xAB}, overControlCap))
156 smallControl := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", 4096)}}}
157 smallLen := len(mustEncodeServer(t, smallControl))
158
159 tests := []struct {
160 name string
161 message protocol.ServerMessage
162 ceilings protoCeilings
163 wantErr error
164 }{
165 {
166 name: "control capped even at absolute negotiation",
167 message: controlOver,
168 ceilings: defaultProtoCeilings(),
169 wantErr: wire.ErrScanLength,
170 },
171 {
172 name: "output rides absolute negotiation",
173 message: outputOver,
174 ceilings: defaultProtoCeilings(),
175 },
176 {
177 name: "output respects lower negotiated ceiling",
178 message: outputOver,
179 ceilings: protoCeilings{maxReceiveEnvelopeBytes: wire.ControlEnvelopeLimit, outputDataLimit: uint64(protocol.MaxOutputDataLen)},
180 wantErr: wire.ErrScanLength,
181 },
182 {
183 name: "control fits negotiated lower ceiling",
184 message: smallControl,
185 ceilings: protoCeilings{maxReceiveEnvelopeBytes: uint64(smallLen), outputDataLimit: uint64(protocol.MaxOutputDataLen)},
186 },
187 {
188 name: "control above negotiated lower ceiling",
189 message: smallControl,
190 ceilings: protoCeilings{maxReceiveEnvelopeBytes: uint64(smallLen - 1), outputDataLimit: uint64(protocol.MaxOutputDataLen)},
191 wantErr: wire.ErrScanLength,
192 },
193 }
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 raw := &scriptedTransport{}
197 conn := &serverConnection{raw: raw, ceilings: tt.ceilings}
198 err := conn.SendServer(tt.message)
199 if tt.wantErr != nil {
200 require.ErrorIs(t, err, tt.wantErr)
201 require.Zero(t, raw.sentLen())
202 return
203 }
204 require.NoError(t, err)
205 require.Equal(t, 1, raw.sentLen())
206 })
207 }
208}
209
210// TestServerSendModesShareControlCategoryCap proves async and owned
211// synchronous server sends enforce the same control cap as the normal path.
212func TestServerSendModesShareControlCategoryCap(t *testing.T) {
213 controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}}
214 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}
215 conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()}
216
217 require.ErrorIs(t, conn.SendServerAsync(controlOver), wire.ErrScanLength)
218 require.ErrorIs(t, conn.SendServerSynchronous(controlOver), wire.ErrScanLength)
219 require.Empty(t, raw.async)
220 require.Empty(t, raw.synchronous)
221}
222
223// TestReceiveCategoryEnvelopeCeilings proves both typed receive paths apply
224// the same category ceilings as sends, including malformed and unknown
225// payloads, which must fall back to the strictest control ceiling.
226func TestReceiveCategoryEnvelopeCeilings(t *testing.T) {
227 serverInputOver := mustEncodeClient(t, protocol.Input{InputSeq: 1, Data: bytes.Repeat([]byte{0xAB}, overControlCap)})
228 clientImageOver := mustEncodeClient(t, protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: bytes.Repeat([]byte{0xAB}, overControlCap)})
229 clientControlOver := mustEncodeServer(t, protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}})
230 clientOutputOver := mustEncodeServer(t, testOutputRaw(bytes.Repeat([]byte{0xAB}, overControlCap)))
231
232 oversizedMalformed := bytes.Repeat([]byte{0xFF}, overControlCap)
233 oversizedUnknown := protowire.AppendBytes(protowire.AppendTag(nil, 99, protowire.BytesType), bytes.Repeat([]byte{0xAB}, overControlCap))
234 smallUnknown := protowire.AppendBytes(protowire.AppendTag(nil, 99, protowire.BytesType), []byte("x"))
235
236 lowerControl := protoCeilings{maxReceiveEnvelopeBytes: wire.ControlEnvelopeLimit, outputDataLimit: uint64(protocol.MaxOutputDataLen)}
237
238 tests := []struct {
239 name string
240 receiver string
241 payload []byte
242 ceilings protoCeilings
243 wantErr error
244 wantCat protocol.DecodeCategory
245 }{
246 {
247 name: "server receive input rides absolute negotiation",
248 receiver: "server",
249 payload: serverInputOver,
250 ceilings: defaultProtoCeilings(),
251 },
252 {
253 name: "server receive image rides absolute negotiation",
254 receiver: "server",
255 payload: clientImageOver,
256 ceilings: defaultProtoCeilings(),
257 },
258 {
259 name: "server receive image respects lower negotiated ceiling",
260 receiver: "server",
261 payload: clientImageOver,
262 ceilings: lowerControl,
263 wantErr: wire.ErrScanLength,
264 wantCat: protocol.DecodeMalformed,
265 },
266 {
267 name: "server receive oversized malformed defaults to control",
268 receiver: "server",
269 payload: oversizedMalformed,
270 ceilings: defaultProtoCeilings(),
271 wantErr: wire.ErrScanLength,
272 wantCat: protocol.DecodeMalformed,
273 },
274 {
275 name: "server receive oversized unknown field defaults to control",
276 receiver: "server",
277 payload: oversizedUnknown,
278 ceilings: defaultProtoCeilings(),
279 wantErr: wire.ErrScanLength,
280 wantCat: protocol.DecodeMalformed,
281 },
282 {
283 name: "server receive small unknown field stays unknown type",
284 receiver: "server",
285 payload: smallUnknown,
286 ceilings: defaultProtoCeilings(),
287 wantErr: wire.ErrScanUnknown,
288 wantCat: protocol.DecodeUnknownType,
289 },
290 {
291 name: "client receive control capped at absolute negotiation",
292 receiver: "client",
293 payload: clientControlOver,
294 ceilings: defaultProtoCeilings(),
295 wantErr: wire.ErrScanLength,
296 wantCat: protocol.DecodeMalformed,
297 },
298 {
299 name: "client receive output rides absolute negotiation",
300 receiver: "client",
301 payload: clientOutputOver,
302 ceilings: defaultProtoCeilings(),
303 },
304 {
305 name: "client receive output respects lower negotiated ceiling",
306 receiver: "client",
307 payload: clientOutputOver,
308 ceilings: lowerControl,
309 wantErr: wire.ErrScanLength,
310 wantCat: protocol.DecodeMalformed,
311 },
312 {
313 name: "client receive oversized malformed defaults to control",
314 receiver: "client",
315 payload: oversizedMalformed,
316 ceilings: defaultProtoCeilings(),
317 wantErr: wire.ErrScanLength,
318 wantCat: protocol.DecodeMalformed,
319 },
320 {
321 name: "client receive small unknown field stays unknown type",
322 receiver: "client",
323 payload: smallUnknown,
324 ceilings: defaultProtoCeilings(),
325 wantErr: wire.ErrScanUnknown,
326 wantCat: protocol.DecodeUnknownType,
327 },
328 }
329 for _, tt := range tests {
330 t.Run(tt.name, func(t *testing.T) {
331 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: tt.payload}}}
332 var err error
333 if tt.receiver == "server" {
334 conn := &serverConnection{raw: raw, ceilings: tt.ceilings}
335 conn.preambleOnce.Do(func() {})
336 _, err = conn.ReceiveClient()
337 } else {
338 conn := &clientConnection{raw: raw, ceilings: tt.ceilings}
339 conn.preambleOnce.Do(func() {})
340 _, err = conn.ReceiveServer()
341 }
342 if tt.wantErr == nil {
343 require.NoError(t, err)
344 return
345 }
346 var failure *protocol.DecodeFailure
347 require.ErrorAs(t, err, &failure)
348 require.ErrorIs(t, failure.Err, tt.wantErr)
349 require.Equal(t, tt.wantCat, failure.Category)
350 })
351 }
352}