1package kittygraphics
2
3import (
4 "bytes"
5 "encoding/base64"
6 "errors"
7 "image"
8 "image/color"
9 "image/png"
10 "math/rand"
11 "os"
12 "strconv"
13 "testing"
14
15 "github.com/bnema/vev-vt/graphics"
16)
17
18func apc(header, payload string) []byte {
19 return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
20}
21
22func pngBytes(t *testing.T, width, height int) []byte {
23 t.Helper()
24 img := image.NewRGBA(image.Rect(0, 0, width, height))
25 img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
26 var out bytes.Buffer
27 if err := png.Encode(&out, img); err != nil {
28 t.Fatal(err)
29 }
30 return out.Bytes()
31}
32
33func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
34 p := NewParser()
35 var events []Event
36 for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
37 events = append(events, p.Feed([]byte{b})...)
38 }
39 var textBefore, textAfter []byte
40 var command Event
41 for _, event := range events {
42 if event.Kind == EventText && command.Kind == 0 {
43 textBefore = append(textBefore, event.Text...)
44 }
45 if event.Kind == EventCommand {
46 command = event
47 }
48 if event.Kind == EventText && command.Kind != 0 {
49 textAfter = append(textAfter, event.Text...)
50 }
51 }
52 if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
53 t.Fatalf("events = %#v", events)
54 }
55 if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
56 t.Fatalf("command = %#v", command.Command)
57 }
58}
59
60func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
61 scene := graphics.NewScene(graphics.Limits{})
62 session := NewSession(scene)
63
64 result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
65 if err != nil {
66 t.Fatal(err)
67 }
68 if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
69 t.Fatalf("response = %q", got)
70 }
71 if got := scene.Usage(); got != (graphics.Usage{}) {
72 t.Fatalf("query persisted graphics state: %#v", got)
73 }
74}
75
76func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78 if err != nil {
79 t.Fatal(err)
80 }
81 scene := graphics.NewScene(graphics.Limits{})
82 session := NewSession(scene)
83 // Feed split boundaries deliberately unrelated to APC boundaries. This
84 // models the short writes observed from the current kitten/icat fixture.
85 rng := rand.New(rand.NewSource(7))
86 var result Result
87 for len(data) != 0 {
88 n := 1 + rng.Intn(4096)
89 if n > len(data) {
90 n = len(data)
91 }
92 part, err := session.Feed(data[:n])
93 if err != nil {
94 t.Fatal(err)
95 }
96 result.Events = append(result.Events, part.Events...)
97 result.Responses = append(result.Responses, part.Responses...)
98 result.Mutations = append(result.Mutations, part.Mutations...)
99 data = data[n:]
100 }
101 if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
102 t.Fatalf("mutations = %#v", result.Mutations)
103 }
104 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
105 t.Fatalf("usage = %#v", got)
106 }
107 if len(result.Responses) != 0 {
108 t.Fatalf("quiet fixture response = %q", result.Bytes())
109 }
110 if _, ok := session.Image(0); ok {
111 t.Fatal("unidentified fixture image was retained as a reusable mapping")
112 }
113}
114
115func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
116 want := []byte{0, 1, 2, 253, 254, 255}
117 standard := base64.StdEncoding.EncodeToString(want)
118 raw := base64.RawStdEncoding.EncodeToString(want)
119 for _, encoded := range []string{standard, raw} {
120 got, err := DecodeBase64([]byte(encoded))
121 if err != nil || !bytes.Equal(got, want) {
122 t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
123 }
124 }
125 if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
126 t.Fatalf("invalid base64 error = %v", err)
127 }
128}
129
130func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
131 scene := graphics.NewScene(graphics.Limits{})
132 session := NewSession(scene)
133 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
134 result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
135 if err != nil {
136 t.Fatal(err)
137 }
138 if len(result.Responses) != 0 || len(result.Mutations) != 1 {
139 t.Fatalf("result = %#v", result)
140 }
141 asset, ok := session.Image(9)
142 if !ok {
143 t.Fatal("image mapping missing")
144 }
145 if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
146 t.Fatal(err)
147 }
148 placement, ok := session.Placement(9, 4)
149 if !ok {
150 t.Fatal("placement mapping missing")
151 }
152 view, ok := scene.Snapshot().Placement(placement)
153 if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
154 t.Fatalf("placement = %#v, ok=%v", view, ok)
155 }
156}
157
158func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
159 scene := graphics.NewScene(graphics.Limits{})
160 session := NewSession(scene)
161 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
162 for _, imageID := range []uint64{1, 2} {
163 if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
164 t.Fatal(err)
165 }
166 if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
167 t.Fatal(err)
168 }
169 }
170 first, ok := session.Placement(1, 7)
171 if !ok {
172 t.Fatal("first placement missing")
173 }
174 second, ok := session.Placement(2, 7)
175 if !ok || first == second {
176 t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
177 }
178 if got := scene.Usage().Placements; got != 2 {
179 t.Fatalf("placements = %d, want 2", got)
180 }
181}
182
183func TestParseAPCAcceptsPayloadFreeCommands(t *testing.T) {
184 for _, raw := range []string{"\x1b_Ga=d,d=a\x1b\\", "\x1b_Ga=p,i=1\x1b\\"} {
185 command, err := ParseAPC([]byte(raw))
186 if err != nil {
187 t.Fatalf("ParseAPC(%q) error = %v", raw, err)
188 }
189 if len(command.Payload) != 0 {
190 t.Fatalf("ParseAPC(%q) payload = %q", raw, command.Payload)
191 }
192 }
193}
194
195func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
196 if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
197 t.Fatalf("duplicate control error = %v", err)
198 }
199 if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
200 t.Fatalf("integer overflow error = %v", err)
201 }
202 if _, err := ParseCommand([]byte("a=q,q=256;")); !errors.Is(err, ErrInvalidCommand) {
203 t.Fatalf("out-of-range quiet error = %v", err)
204 }
205 if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
206 t.Fatalf("mixed image selector error = %v", err)
207 }
208
209 p := NewParser(Limits{MaxAPCBytes: 8})
210 events := p.Feed(apc("a=q", "0123456789"))
211 if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
212 t.Fatalf("oversize events = %#v", events)
213 }
214 p = NewParser()
215 if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
216 t.Fatalf("truncated feed events = %#v", got)
217 }
218 got := p.Finish()
219 if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
220 t.Fatalf("truncated finish events = %#v", got)
221 }
222
223 session := NewSession(graphics.NewScene(graphics.Limits{}))
224 first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
225 if _, err := session.Feed(first); err != nil {
226 t.Fatal(err)
227 }
228 if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
229 t.Fatalf("interleaved error = %v", err)
230 }
231 if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
232 t.Fatal(err)
233 }
234}
235
236func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
237 for _, action := range []string{"finish", "delete"} {
238 t.Run(action, func(t *testing.T) {
239 scene := graphics.NewScene(graphics.Limits{})
240 session := NewSession(scene)
241 if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
242 t.Fatal(err)
243 }
244 if action == "finish" {
245 if _, err := session.Finish(); err != nil {
246 t.Fatalf("Finish error = %v", err)
247 }
248 } else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
249 t.Fatal(err)
250 }
251 if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
252 t.Fatal("stale continuation unexpectedly succeeded")
253 }
254 if got := scene.Usage(); got != (graphics.Usage{}) {
255 t.Fatalf("stale continuation mutated scene: %#v", got)
256 }
257 })
258 }
259}
260
261func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
262 good := pngBytes(t, 1, 1)
263 scene := graphics.NewScene(graphics.Limits{})
264 session := NewSession(scene)
265 if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
266 t.Fatalf("malformed PNG error = %v", err)
267 }
268 if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
269 t.Fatalf("malformed PNG mutated scene: %#v", got)
270 }
271}
272
273func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
274 controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
275 limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
276 if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
277 t.Fatalf("overflowing geometry error = %v", err)
278 }
279}
280
281func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
282 scene := graphics.NewScene(graphics.Limits{})
283 session := NewSession(scene)
284 first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
285 if _, err := session.Feed(first); err != nil {
286 t.Fatal(err)
287 }
288 last := apc("m=0,q=2", "IDBA")
289 result, err := session.Feed(last)
290 if err != nil {
291 t.Fatal(err)
292 }
293 if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
294 t.Fatalf("mutations = %#v", result.Mutations)
295 }
296 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
297 t.Fatalf("usage = %#v", got)
298 }
299}
300
301func TestControlsIgnoreExtensionsAndBoundIntegerFields(t *testing.T) {
302 controls, err := ParseControls([]byte("a=p,i=1,N=1,S=2,O=3"))
303 if err != nil || controls.ImageID != 1 {
304 t.Fatalf("extension controls = %#v, %v", controls, err)
305 }
306 if _, err := ParseControls([]byte("a=p,s=4294967296")); !errors.Is(err, ErrIntegerOverflow) {
307 t.Fatalf("out-of-range uint32 control error = %v", err)
308 }
309 if _, err := ParseControls([]byte("a=p,z=2147483648")); !errors.Is(err, ErrIntegerOverflow) {
310 t.Fatalf("out-of-range int32 control error = %v", err)
311 }
312}
313
314func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
315 session := NewSession(nil)
316 session.nextPlacement = MaxKittyID + 1
317 if _, ok := session.takePlacementID(1); ok {
318 t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
319 }
320}
321
322func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
323 session := NewSession(graphics.NewScene(graphics.Limits{}))
324 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
325 if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", payload)); err != nil {
326 t.Fatal(err)
327 }
328 for _, header := range []string{"a=p,i=1,P=2", "a=p,i=1,H=1", "a=d,d=p,x=1,y=1", "a=d,d=P,x=1,y=1"} {
329 if _, err := session.Feed(apc(header, "")); !errors.Is(err, ErrUnsupported) {
330 t.Fatalf("%q error = %v, want ErrUnsupported", header, err)
331 }
332 }
333}
334
335func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
336 data := pngBytes(t, 2, 2)
337 if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) {
338 t.Fatalf("PNG byte-budget error = %v", err)
339 }
340 scene := graphics.NewScene(graphics.Limits{})
341 session := NewSession(scene)
342 payload := base64.RawStdEncoding.EncodeToString(data)
343 if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", payload)); err != nil {
344 t.Fatal(err)
345 }
346 assetID, ok := session.Image(1)
347 if !ok {
348 t.Fatal("image mapping missing")
349 }
350 asset, ok := scene.Snapshot().Asset(assetID)
351 if !ok || asset.Format() != graphics.AssetFormatPNG || asset.Blob().Format != graphics.AssetFormatPNG {
352 t.Fatalf("asset format = %#v, ok=%v", asset, ok)
353 }
354}
355
356func TestPlacementFailureEmitsStableProtocolError(t *testing.T) {
357 scene := graphics.NewScene(graphics.Limits{})
358 session := NewSession(scene)
359 if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
360 t.Fatal(err)
361 }
362 result, err := session.Feed(apc("a=p,i=1,p=7,w=2,q=0", ""))
363 if !errors.Is(err, graphics.ErrInvalidPlacement) {
364 t.Fatalf("placement error = %v", err)
365 }
366 if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
367 t.Fatalf("placement response = %q", got)
368 }
369 if len(result.Mutations) != 0 || scene.Usage().Placements != 0 {
370 t.Fatalf("failed placement mutated state: result=%#v usage=%#v", result, scene.Usage())
371 }
372}
373
374func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
375 scene := graphics.NewScene(graphics.Limits{MaxEncodedBytes: 4, MaxDecodedPixels: 4})
376 session := NewSession(scene)
377 if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
378 t.Fatal(err)
379 }
380 if _, err := session.Feed(apc("a=p,i=1,p=7,q=2", "")); err != nil {
381 t.Fatal(err)
382 }
383 assetID, ok := session.Image(1)
384 if !ok {
385 t.Fatal("original image mapping missing")
386 }
387 placementID, ok := session.Placement(1, 7)
388 if !ok {
389 t.Fatal("original placement mapping missing")
390 }
391 before := scene.Snapshot()
392 result, err := session.Feed(apc("a=t,i=1,f=32,s=2,v=1,q=0", "AQIDBAUGBwg"))
393 if !errors.Is(err, graphics.ErrEncodedBudget) {
394 t.Fatalf("replacement error = %v", err)
395 }
396 if got := result.Bytes(); string(got) != "\x1b_Gi=1;E2BIG\x1b\\" {
397 t.Fatalf("replacement response = %q", got)
398 }
399 if got, ok := session.Image(1); !ok || got != assetID {
400 t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
401 }
402 if got, ok := session.Placement(1, 7); !ok || got != placementID {
403 t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
404 }
405 if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
406 t.Fatalf("failed replacement changed scene: before=%#v after=%#v", before.Usage(), scene.Usage())
407 }
408 if _, ok := scene.Snapshot().Placement(placementID); !ok {
409 t.Fatal("failed replacement removed original placement")
410 }
411}
412
413func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
414 scene := graphics.NewScene(graphics.Limits{})
415 session := NewSession(scene)
416 if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
417 t.Fatal(err)
418 }
419 assetID, ok := session.Image(1)
420 if !ok {
421 t.Fatal("original image mapping missing")
422 }
423 placementID, ok := session.Placement(1, 1)
424 if !ok {
425 t.Fatal("original placement mapping missing")
426 }
427
428 result, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,w=2,q=0", "AQIDBA"))
429 if !errors.Is(err, graphics.ErrInvalidPlacement) {
430 t.Fatalf("replacement placement error = %v", err)
431 }
432 if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
433 t.Fatalf("replacement response = %q", got)
434 }
435 if got, ok := session.Image(1); !ok || got != assetID {
436 t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
437 }
438 if got, ok := session.Placement(1, 1); !ok || got != placementID {
439 t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
440 }
441 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
442 t.Fatalf("failed displayed replacement usage = %#v", got)
443 }
444}
445
446func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
447 scene := graphics.NewScene(graphics.Limits{})
448 session := NewSession(scene)
449 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
450 for range 2 {
451 if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
452 t.Fatal(err)
453 }
454 }
455 if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
456 t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
457 }
458 if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
459 t.Fatal(err)
460 }
461 if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
462 t.Fatal(err)
463 }
464 if got := scene.Usage(); got.Placements != 2 {
465 t.Fatalf("placements = %#v", got)
466 }
467 if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
468 t.Fatal(err)
469 }
470 if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
471 t.Fatalf("selected delete affected unrelated scene state: %#v", got)
472 }
473 if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
474 t.Fatal(err)
475 }
476 if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
477 t.Fatalf("image-number delete = %#v", got)
478 }
479 if _, ok := session.Image(1); !ok {
480 t.Fatal("image ID namespace was removed by image-number delete")
481 }
482}
483
484func FuzzParserNeverPanics(f *testing.F) {
485 f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
486 f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
487 f.Fuzz(func(t *testing.T, data []byte) {
488 p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
489 _ = p.Feed(data)
490 _ = p.Finish()
491 })
492}
493
494func FuzzSessionNeverPanics(f *testing.F) {
495 f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
496 f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
497 f.Fuzz(func(t *testing.T, data []byte) {
498 s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
499 MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
500 MaxDimension: 128, MaxDecodedPixels: 4096,
501 })
502 _, _ = s.Feed(data)
503 _, _ = s.Finish()
504 })
505}