feat(graphics): decode zlib-compressed image data
- commit
- b0cc038c08de12f40c97f20788184c81d31df56a
- author
- brice · · 2026-08-26 12:57 UTC
- parent
- 49ed8a6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 65 | 65 | } | |
| 66 | 66 | c.Action, c.HasAction = Action(value[0]), true | |
| 67 | 67 | 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) | |
| 71 | 70 | } | |
| 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 | |
| 73 | 73 | case ControlFormat: | |
| 74 | 74 | n, err := parseUint(value) | |
| 75 | 75 | 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]), true | 66 | 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, true | 71 | + | // 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 ( | |||
| 145 | 145 | ||
| 146 | 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 | 153 | // Quiet controls protocol response emission. Kitty uses q=1 for successful | |
| 149 | 154 | // operations only and q=2 for all responses. | |
| 150 | 155 | type Quiet uint8 | |
| @@ -213,7 +218,7 @@ const ( | |||
| 213 | 218 | type Controls struct { | |
| 214 | 219 | Action Action | |
| 215 | 220 | HasAction bool | |
| 216 | - | Compression uint64 | |
| 221 | + | Compression Compression | |
| 217 | 222 | HasCompression bool | |
| 218 | 223 | Format Format | |
| 219 | 224 | 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 successful | 153 | // 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 uint8 | 155 | type Quiet uint8 | ||
| @@ -213,7 +218,7 @@ const ( | |||||
| 213 | type Controls struct { | 218 | type Controls struct { | ||
| 214 | Action Action | 219 | Action Action | ||
| 215 | HasAction bool | 220 | HasAction bool | ||
| 216 | - | Compression uint64 | 221 | + | Compression Compression |
| 217 | HasCompression bool | 222 | HasCompression bool | ||
| 218 | Format Format | 223 | Format Format | ||
| 219 | HasFormat bool | 224 | HasFormat bool | ||
M protocol/kittygraphics/kittygraphics_test.go +46 -0
| @@ -2,6 +2,7 @@ package kittygraphics | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "compress/zlib" | |
| 5 | 6 | "encoding/base64" | |
| 6 | 7 | "errors" | |
| 7 | 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 | 122 | func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) { | |
| 77 | 123 | data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin") | |
| 78 | 124 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "compress/zlib" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "image/png" | |
| 8 | + | "io" | |
| 7 | 9 | "math" | |
| 8 | 10 | "strconv" | |
| 9 | 11 | ||
| @@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) { | |||
| 207 | 209 | if !c.HasAction { | |
| 208 | 210 | action = ActionTransmit | |
| 209 | 211 | } | |
| 210 | - | if c.HasCompression && c.Compression != 0 { | |
| 212 | + | if c.HasCompression && c.Compression != CompressionZlib { | |
| 211 | 213 | return s.failure(c, controlsImageID(c), ErrUnsupported) | |
| 212 | 214 | } | |
| 213 | 215 | if c.HasTransmission && c.Transmission != TransmissionDirect { | |
| @@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, | |||
| 299 | 301 | if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes { | |
| 300 | 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 | 305 | if err != nil { | |
| 304 | 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 | 392 | 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 | 425 | func assetFormat(format Format) graphics.AssetFormat { | |
| 394 | 426 | switch format { | |
| 395 | 427 | 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 = ActionTransmit | 210 | 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, nil | 392 | 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: | ||