detached b0cc038

feat(graphics): decode zlib-compressed image data

commit
b0cc038c08de12f40c97f20788184c81d31df56a
author
brice · · 2026-08-26 12:57 UTC
parent
49ed8a6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +90 -7
M protocol/kittygraphics/controls.go +4 -4
@@ -65,11 +65,11 @@ func parseControl(c *Controls, key byte, value []byte) error {
6565 }
6666 c.Action, c.HasAction = Action(value[0]), true
6767 case ControlCompression:
68- n, err := parseUint(value)
69- if err != nil {
70- return fieldError(key, err)
68+ if len(value) != 1 {
69+ return fmt.Errorf("%w: o=%q", ErrInvalidCommand, value)
7170 }
72- c.Compression, c.HasCompression = n, true
71+ // Preserve unknown one-byte algorithms so Session can return ENOTSUP.
72+ c.Compression, c.HasCompression = Compression(value[0]), true
7373 case ControlFormat:
7474 n, err := parseUint(value)
7575 if err != nil {
@@ -65,11 +65,11 @@ func parseControl(c *Controls, key byte, value []byte) error {
65 }65 }
66 c.Action, c.HasAction = Action(value[0]), true66 c.Action, c.HasAction = Action(value[0]), true
67 case ControlCompression:67 case ControlCompression:
68- n, err := parseUint(value)68+ if len(value) != 1 {
69- if err != nil {69+ return fmt.Errorf("%w: o=%q", ErrInvalidCommand, value)
70- return fieldError(key, err)
71 }70 }
72- c.Compression, c.HasCompression = n, true71+ // Preserve unknown one-byte algorithms so Session can return ENOTSUP.
72+ c.Compression, c.HasCompression = Compression(value[0]), true
73 case ControlFormat:73 case ControlFormat:
74 n, err := parseUint(value)74 n, err := parseUint(value)
75 if err != nil {75 if err != nil {
M protocol/kittygraphics/kittygraphics.go +6 -1
@@ -145,6 +145,11 @@ const (
145145
146146 func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
147147
148+// Compression is the optional compression applied to raw image data.
149+type Compression byte
150+
151+const CompressionZlib Compression = 'z'
152+
148153 // Quiet controls protocol response emission. Kitty uses q=1 for successful
149154 // operations only and q=2 for all responses.
150155 type Quiet uint8
@@ -213,7 +218,7 @@ const (
213218 type Controls struct {
214219 Action Action
215220 HasAction bool
216- Compression uint64
221+ Compression Compression
217222 HasCompression bool
218223 Format Format
219224 HasFormat bool
@@ -145,6 +145,11 @@ const (
145 145
146 func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }146 func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
147 147
148+// Compression is the optional compression applied to raw image data.
149+type Compression byte
150+
151+const CompressionZlib Compression = 'z'
152+
148 // Quiet controls protocol response emission. Kitty uses q=1 for successful153 // Quiet controls protocol response emission. Kitty uses q=1 for successful
149 // operations only and q=2 for all responses.154 // operations only and q=2 for all responses.
150 type Quiet uint8155 type Quiet uint8
@@ -213,7 +218,7 @@ const (
213 type Controls struct {218 type Controls struct {
214 Action Action219 Action Action
215 HasAction bool220 HasAction bool
216- Compression uint64221+ Compression Compression
217 HasCompression bool222 HasCompression bool
218 Format Format223 Format Format
219 HasFormat bool224 HasFormat bool
M protocol/kittygraphics/kittygraphics_test.go +46 -0
@@ -2,6 +2,7 @@ package kittygraphics
22
33 import (
44 "bytes"
5+ "compress/zlib"
56 "encoding/base64"
67 "errors"
78 "image"
@@ -73,6 +74,51 @@ func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
7374 }
7475 }
7576
77+func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) {
78+ raw := []byte{
79+ 255, 0, 0, 0, 255, 0,
80+ 0, 0, 255, 255, 255, 255,
81+ }
82+ var compressed bytes.Buffer
83+ zw := zlib.NewWriter(&compressed)
84+ if _, err := zw.Write(raw); err != nil {
85+ t.Fatal(err)
86+ }
87+ if err := zw.Close(); err != nil {
88+ t.Fatal(err)
89+ }
90+
91+ scene := graphics.NewScene(graphics.Limits{})
92+ session := NewSession(scene)
93+ payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
94+ result, err := session.Feed(apc("a=T,q=2,f=24,o=z,s=2,v=2", payload))
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
99+ t.Fatalf("mutations = %#v", result.Mutations)
100+ }
101+ assets := scene.Snapshot().Assets()
102+ if len(assets) != 1 || assets[0].Format() != graphics.AssetFormatRGB || !bytes.Equal(assets[0].Encoded(), raw) {
103+ t.Fatalf("assets = %#v", assets)
104+ }
105+}
106+
107+func TestSessionBoundsZlibDecompression(t *testing.T) {
108+ raw := bytes.Repeat([]byte{1}, 64)
109+ var compressed bytes.Buffer
110+ zw := zlib.NewWriter(&compressed)
111+ _, _ = zw.Write(raw)
112+ _ = zw.Close()
113+
114+ session := NewSession(graphics.NewScene(graphics.Limits{}), Limits{MaxDecodedBytes: 32})
115+ payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
116+ _, err := session.Feed(apc("a=T,f=24,o=z,s=1,v=1", payload))
117+ if !errors.Is(err, ErrPayloadTooLarge) {
118+ t.Fatalf("error = %v, want ErrPayloadTooLarge", err)
119+ }
120+}
121+
76122 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77123 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78124 if err != nil {
@@ -2,6 +2,7 @@ package kittygraphics
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "compress/zlib"
5 "encoding/base64"6 "encoding/base64"
6 "errors"7 "errors"
7 "image"8 "image"
@@ -73,6 +74,51 @@ func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
73 }74 }
74 }75 }
75 76
77+func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) {
78+ raw := []byte{
79+ 255, 0, 0, 0, 255, 0,
80+ 0, 0, 255, 255, 255, 255,
81+ }
82+ var compressed bytes.Buffer
83+ zw := zlib.NewWriter(&compressed)
84+ if _, err := zw.Write(raw); err != nil {
85+ t.Fatal(err)
86+ }
87+ if err := zw.Close(); err != nil {
88+ t.Fatal(err)
89+ }
90+
91+ scene := graphics.NewScene(graphics.Limits{})
92+ session := NewSession(scene)
93+ payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
94+ result, err := session.Feed(apc("a=T,q=2,f=24,o=z,s=2,v=2", payload))
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
99+ t.Fatalf("mutations = %#v", result.Mutations)
100+ }
101+ assets := scene.Snapshot().Assets()
102+ if len(assets) != 1 || assets[0].Format() != graphics.AssetFormatRGB || !bytes.Equal(assets[0].Encoded(), raw) {
103+ t.Fatalf("assets = %#v", assets)
104+ }
105+}
106+
107+func TestSessionBoundsZlibDecompression(t *testing.T) {
108+ raw := bytes.Repeat([]byte{1}, 64)
109+ var compressed bytes.Buffer
110+ zw := zlib.NewWriter(&compressed)
111+ _, _ = zw.Write(raw)
112+ _ = zw.Close()
113+
114+ session := NewSession(graphics.NewScene(graphics.Limits{}), Limits{MaxDecodedBytes: 32})
115+ payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
116+ _, err := session.Feed(apc("a=T,f=24,o=z,s=1,v=1", payload))
117+ if !errors.Is(err, ErrPayloadTooLarge) {
118+ t.Fatalf("error = %v, want ErrPayloadTooLarge", err)
119+ }
120+}
121+
76 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {122 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")123 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78 if err != nil {124 if err != nil {
M protocol/kittygraphics/session.go +34 -2
@@ -2,8 +2,10 @@ package kittygraphics
22
33 import (
44 "bytes"
5+ "compress/zlib"
56 "fmt"
67 "image/png"
8+ "io"
79 "math"
810 "strconv"
911
@@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
207209 if !c.HasAction {
208210 action = ActionTransmit
209211 }
210- if c.HasCompression && c.Compression != 0 {
212+ if c.HasCompression && c.Compression != CompressionZlib {
211213 return s.failure(c, controlsImageID(c), ErrUnsupported)
212214 }
213215 if c.HasTransmission && c.Transmission != TransmissionDirect {
@@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
299301 if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
300302 return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
301303 }
302- decoded, err := DecodeBase64(value.payload)
304+ decoded, err := decodeImagePayload(value.payload, value.controls, s.limits)
303305 if err != nil {
304306 return s.failure(value.controls, value.imageID, err)
305307 }
@@ -390,6 +392,36 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
390392 return s.success(value.controls, value.imageID), mutation, nil
391393 }
392394
395+func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byte, error) {
396+ decoded, err := DecodeBase64(payload)
397+ if err != nil {
398+ return nil, err
399+ }
400+ if !controls.HasCompression {
401+ return decoded, nil
402+ }
403+ if controls.Compression != CompressionZlib {
404+ return nil, ErrUnsupported
405+ }
406+ zr, err := zlib.NewReader(bytes.NewReader(decoded))
407+ if err != nil {
408+ return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
409+ }
410+ defer zr.Close()
411+ limit := limits.MaxDecodedBytes
412+ if limit >= math.MaxInt64 {
413+ limit = math.MaxInt64 - 1
414+ }
415+ decompressed, err := io.ReadAll(io.LimitReader(zr, int64(limit)+1))
416+ if err != nil {
417+ return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
418+ }
419+ if uint64(len(decompressed)) > limits.MaxDecodedBytes {
420+ return nil, ErrPayloadTooLarge
421+ }
422+ return decompressed, nil
423+}
424+
393425 func assetFormat(format Format) graphics.AssetFormat {
394426 switch format {
395427 case FormatRGB:
@@ -2,8 +2,10 @@ package kittygraphics
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "compress/zlib"
5 "fmt"6 "fmt"
6 "image/png"7 "image/png"
8+ "io"
7 "math"9 "math"
8 "strconv"10 "strconv"
9 11
@@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
207 if !c.HasAction {209 if !c.HasAction {
208 action = ActionTransmit210 action = ActionTransmit
209 }211 }
210- if c.HasCompression && c.Compression != 0 {212+ if c.HasCompression && c.Compression != CompressionZlib {
211 return s.failure(c, controlsImageID(c), ErrUnsupported)213 return s.failure(c, controlsImageID(c), ErrUnsupported)
212 }214 }
213 if c.HasTransmission && c.Transmission != TransmissionDirect {215 if c.HasTransmission && c.Transmission != TransmissionDirect {
@@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
299 if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {301 if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
300 return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)302 return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
301 }303 }
302- decoded, err := DecodeBase64(value.payload)304+ decoded, err := decodeImagePayload(value.payload, value.controls, s.limits)
303 if err != nil {305 if err != nil {
304 return s.failure(value.controls, value.imageID, err)306 return s.failure(value.controls, value.imageID, err)
305 }307 }
@@ -390,6 +392,36 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
390 return s.success(value.controls, value.imageID), mutation, nil392 return s.success(value.controls, value.imageID), mutation, nil
391 }393 }
392 394
395+func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byte, error) {
396+ decoded, err := DecodeBase64(payload)
397+ if err != nil {
398+ return nil, err
399+ }
400+ if !controls.HasCompression {
401+ return decoded, nil
402+ }
403+ if controls.Compression != CompressionZlib {
404+ return nil, ErrUnsupported
405+ }
406+ zr, err := zlib.NewReader(bytes.NewReader(decoded))
407+ if err != nil {
408+ return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
409+ }
410+ defer zr.Close()
411+ limit := limits.MaxDecodedBytes
412+ if limit >= math.MaxInt64 {
413+ limit = math.MaxInt64 - 1
414+ }
415+ decompressed, err := io.ReadAll(io.LimitReader(zr, int64(limit)+1))
416+ if err != nil {
417+ return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
418+ }
419+ if uint64(len(decompressed)) > limits.MaxDecodedBytes {
420+ return nil, ErrPayloadTooLarge
421+ }
422+ return decompressed, nil
423+}
424+
393 func assetFormat(format Format) graphics.AssetFormat {425 func assetFormat(format Format) graphics.AssetFormat {
394 switch format {426 switch format {
395 case FormatRGB:427 case FormatRGB:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close