This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 24cd5be0f feat(puffin): read compressed blobs (#2400)
24cd5be0f is described below
commit 24cd5be0f33cdb0defd2c1a5cd77c0449f9cc9ec
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:01:58 2026 +0100
feat(puffin): read compressed blobs (#2400)
* feat(puffin): read compressed blobs
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): reject trailing zstd frames
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): reject compressed deletion vectors on read
* test(puffin): reject compressed DV footer and external metadata
* fix(puffin): allow common zstd windows for small blobs
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): defer DV compression rejection to blob reads
* test(puffin): preserve DV reader error contract
* fix(puffin): reject explicit empty compression codec
* refactor(puffin): share LZ4 and Zstd codec constants
---------
Signed-off-by: Minh Vu <[email protected]>
---
puffin/blob_compression.go | 167 +++++++++++++++++++++++
puffin/blob_compression_test.go | 290 ++++++++++++++++++++++++++++++++++++++++
puffin/puffin_reader.go | 22 ++-
puffin/puffin_test.go | 25 ++--
4 files changed, 492 insertions(+), 12 deletions(-)
diff --git a/puffin/blob_compression.go b/puffin/blob_compression.go
new file mode 100644
index 000000000..0bc1b9328
--- /dev/null
+++ b/puffin/blob_compression.go
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package puffin
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+
+ "github.com/klauspost/compress/zstd"
+ "github.com/pierrec/lz4/v4"
+)
+
+// CompressionCodecLZ4 is the Puffin LZ4 frame codec.
+const CompressionCodecLZ4 = "lz4"
+
+// CompressionCodecZstd is the Puffin Zstandard frame codec.
+const CompressionCodecZstd = "zstd"
+
+// readCompressedBlob decompresses one Puffin blob frame. Puffin requires
+// compressed blobs to include their uncompressed content size in the frame.
+// Bound both the advertised size and the actual output to maxBlobSize.
+func readCompressedBlob(data []byte, codec string, maxBlobSize int64) ([]byte,
error) {
+ if maxBlobSize <= 0 {
+ return nil, fmt.Errorf("puffin: invalid maximum blob size %d",
maxBlobSize)
+ }
+
+ var (
+ decoded io.Reader
+ expectedSize uint64
+ closeDecoder func()
+ )
+
+ switch codec {
+ case CompressionCodecLZ4:
+ input := bytes.NewReader(data)
+ header, err := readLZ4FrameHeader(input)
+ if err != nil {
+ return nil, fmt.Errorf("puffin: invalid LZ4 blob frame:
%w", err)
+ }
+ if !header.hasContentSize {
+ return nil, errors.New("puffin: LZ4 blob frame is
missing content size")
+ }
+ expectedSize = header.contentSize
+ if expectedSize > uint64(maxBlobSize) {
+ return nil, fmt.Errorf("puffin: decompressed blob
exceeds maximum size %d", maxBlobSize)
+ }
+ if err := validateLZ4FrameEnvelope(
+ input, int64(len(data)), header.flags,
header.headerSize, header.blockMaxSize,
+ ); err != nil {
+ return nil, fmt.Errorf("puffin: invalid LZ4 blob frame:
%w", err)
+ }
+ decoded = lz4.NewReader(bytes.NewReader(data))
+
+ case CompressionCodecZstd:
+ var header zstd.Header
+ if err := header.Decode(data); err != nil {
+ return nil, fmt.Errorf("puffin: invalid zstd blob
frame: %w", err)
+ }
+ if header.Skippable || !header.HasFCS {
+ return nil, errors.New("puffin: zstd blob frame is
missing content size")
+ }
+ expectedSize = header.FrameContentSize
+ if expectedSize > uint64(maxBlobSize) {
+ return nil, fmt.Errorf("puffin: decompressed blob
exceeds maximum size %d", maxBlobSize)
+ }
+ if err := validateZstdFrameEnvelope(data, header); err != nil {
+ return nil, fmt.Errorf("puffin: invalid zstd blob
frame: %w", err)
+ }
+ // The decoder may need a window larger than a tiny caller-set
blob
+ // limit, but its output is still strictly bounded below. Allow
at least
+ // an 8 MiB window for small blobs written with standard
encoders.
+ decoder, err := zstd.NewReader(
+ bytes.NewReader(data),
+ zstd.WithDecoderConcurrency(1),
+ zstd.WithDecoderMaxMemory(max(uint64(maxBlobSize),
uint64(8<<20))),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("puffin: create zstd blob
decoder: %w", err)
+ }
+ decoded = decoder
+ closeDecoder = decoder.Close
+
+ default:
+ return nil, fmt.Errorf("puffin: unsupported blob compression
codec %q", codec)
+ }
+ if closeDecoder != nil {
+ defer closeDecoder()
+ }
+
+ // Reading one byte past the limit catches corrupt content-size headers
and
+ // decompression bombs without allocating unbounded output buffers.
+ limit := maxBlobSize
+ if limit < math.MaxInt64 {
+ limit++
+ }
+ output, err := io.ReadAll(io.LimitReader(decoded, limit))
+ if err != nil {
+ return nil, fmt.Errorf("puffin: decompress %s blob: %w", codec,
err)
+ }
+ if int64(len(output)) > maxBlobSize {
+ return nil, fmt.Errorf("puffin: decompressed blob exceeds
maximum size %d", maxBlobSize)
+ }
+ if uint64(len(output)) != expectedSize {
+ return nil, fmt.Errorf("puffin: decompressed %s blob size %d
does not match frame content size %d", codec, len(output), expectedSize)
+ }
+
+ return output, nil
+}
+
+// validateZstdFrameEnvelope ensures the blob contains exactly one frame.
+// The decoder accepts concatenated or skippable frames, which Puffin does not.
+func validateZstdFrameEnvelope(data []byte, header zstd.Header) error {
+ offset := header.HeaderSize
+ for {
+ if len(data)-offset < 3 {
+ return errors.New("truncated zstd block header")
+ }
+ block := uint32(data[offset]) | uint32(data[offset+1])<<8 |
uint32(data[offset+2])<<16
+ offset += 3
+ last := block&1 != 0
+ kind := (block >> 1) & 3
+ blockSize := int(block >> 3)
+ switch kind {
+ case 0, 2:
+ case 1:
+ blockSize = 1
+ default:
+ return errors.New("reserved zstd block type")
+ }
+ if blockSize > len(data)-offset {
+ return errors.New("truncated zstd block")
+ }
+ offset += blockSize
+ if last {
+ break
+ }
+ }
+ if header.HasCheckSum {
+ if len(data)-offset < 4 {
+ return errors.New("truncated zstd content checksum")
+ }
+ offset += 4
+ }
+ if offset != len(data) {
+ return errors.New("zstd frame has trailing data")
+ }
+
+ return nil
+}
diff --git a/puffin/blob_compression_test.go b/puffin/blob_compression_test.go
new file mode 100644
index 000000000..0fc1d7620
--- /dev/null
+++ b/puffin/blob_compression_test.go
@@ -0,0 +1,290 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package puffin_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "testing"
+
+ "github.com/apache/iceberg-go/puffin"
+ "github.com/klauspost/compress/zstd"
+ "github.com/pierrec/lz4/v4"
+ "github.com/stretchr/testify/require"
+)
+
+func compressedBlobTestFile(t *testing.T, codec string, payload []byte) []byte
{
+ t.Helper()
+
+ return compressedBlobTestFileWithMetadata(t, payload,
puffin.BlobMetadata{
+ Type: puffin.BlobTypeDataSketchesTheta,
+ Fields: []int32{1},
+ SnapshotID: 42,
+ SequenceNumber: 7,
+ Offset: puffin.MagicSize,
+ Length: int64(len(payload)),
+ CompressionCodec: &codec,
+ })
+}
+
+func compressedBlobTestFileWithMetadata(t *testing.T, payload []byte, metadata
puffin.BlobMetadata) []byte {
+ t.Helper()
+ footer, err := json.Marshal(puffin.Footer{Blobs:
[]puffin.BlobMetadata{metadata}})
+ require.NoError(t, err)
+
+ var data bytes.Buffer
+ data.WriteString("PFA1")
+ data.Write(payload)
+ data.WriteString("PFA1")
+ data.Write(footer)
+ var trailer [12]byte
+ binary.LittleEndian.PutUint32(trailer[:4], uint32(len(footer)))
+ copy(trailer[8:], "PFA1")
+ data.Write(trailer[:])
+
+ return data.Bytes()
+}
+
+func compressedBlobTestPayload(t *testing.T, codec string, data []byte) []byte
{
+ t.Helper()
+
+ var output bytes.Buffer
+ switch codec {
+ case puffin.CompressionCodecLZ4:
+ writer := lz4.NewWriter(&output)
+ require.NoError(t,
writer.Apply(lz4.SizeOption(uint64(len(data)))))
+ _, err := writer.Write(data)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ case puffin.CompressionCodecZstd:
+ writer, err := zstd.NewWriter(nil)
+ require.NoError(t, err)
+ defer writer.Close()
+
+ return writer.EncodeAll(data, nil)
+ }
+
+ return output.Bytes()
+}
+
+func TestReadCompressedPuffinBlobs(t *testing.T) {
+ raw := bytes.Repeat([]byte("iceberg blob content "), 256)
+ for _, codec := range []string{puffin.CompressionCodecLZ4,
puffin.CompressionCodecZstd} {
+ t.Run(codec, func(t *testing.T) {
+ stored := compressedBlobTestPayload(t, codec, raw)
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t, codec, stored)))
+ require.NoError(t, err)
+
+ blob, err := r.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, raw, blob.Data)
+ require.Equal(t, int64(len(stored)),
blob.Metadata.Length)
+ require.Equal(t, codec, *blob.Metadata.CompressionCodec)
+
+ byMetadata, err := r.ReadBlobByMetadata(r.Blobs()[0])
+ require.NoError(t, err)
+ require.Equal(t, raw, byMetadata)
+
+ all, err := r.ReadAllBlobs()
+ require.NoError(t, err)
+ require.Len(t, all, 1)
+ require.Equal(t, raw, all[0].Data)
+ })
+ }
+}
+
+func TestReadCompressedPuffinBlobHonorsUncompressedLimit(t *testing.T) {
+ raw := bytes.Repeat([]byte("a"), 4096)
+ for _, codec := range []string{puffin.CompressionCodecLZ4,
puffin.CompressionCodecZstd} {
+ t.Run(codec, func(t *testing.T) {
+ stored := compressedBlobTestPayload(t, codec, raw)
+ require.Less(t, len(stored), 128)
+ r, err := puffin.NewReader(
+ bytes.NewReader(compressedBlobTestFile(t,
codec, stored)),
+ puffin.WithMaxBlobSize(128),
+ )
+ require.NoError(t, err)
+
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "decompressed blob
exceeds maximum size")
+ })
+ }
+}
+
+func TestReadCompressedPuffinBlobRejectsInvalidFrames(t *testing.T) {
+ raw := []byte("Puffin blob frame")
+ t.Run("unknown codec", func(t *testing.T) {
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t, "gzip", raw)))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "not supported")
+ })
+
+ t.Run("explicit empty codec", func(t *testing.T) {
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t, "", raw)))
+ require.NoError(t, err)
+
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, `compression "" not supported`)
+ _, err = r.ReadBlobByMetadata(r.Blobs()[0])
+ require.ErrorContains(t, err, `compression "" not supported`)
+ _, err = r.ReadAllBlobs()
+ require.ErrorContains(t, err, `compression "" not supported`)
+ })
+
+ t.Run("lz4 missing content size", func(t *testing.T) {
+ var output bytes.Buffer
+ writer := lz4.NewWriter(&output)
+ _, err := writer.Write(raw)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecLZ4, output.Bytes())))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "missing content size")
+ })
+
+ t.Run("lz4 trailing frame", func(t *testing.T) {
+ frame := compressedBlobTestPayload(t,
puffin.CompressionCodecLZ4, raw)
+ frame = append(frame, 0xff)
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecLZ4, frame)))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "trailing data")
+ })
+
+ t.Run("zstd missing content size", func(t *testing.T) {
+ var output bytes.Buffer
+ writer, err := zstd.NewWriter(&output)
+ require.NoError(t, err)
+ _, err = writer.Write(raw)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecZstd, output.Bytes())))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "missing content size")
+ })
+}
+
+func TestReadCompressedPuffinBlobRejectsZstdTrailingFrames(t *testing.T) {
+ data := bytes.Repeat([]byte("single frame"), 256)
+ frame := compressedBlobTestPayload(t, puffin.CompressionCodecZstd, data)
+ for _, tc := range []struct {
+ name string
+ trailing []byte
+ }{
+ {"empty frame", []byte{0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x00,
0x01, 0x00, 0x00}},
+ {"skippable frame", []byte{0x50, 0x2a, 0x4d, 0x18, 0, 0, 0, 0}},
+ {"nonempty frame", compressedBlobTestPayload(t,
puffin.CompressionCodecZstd, []byte("other"))},
+ {"trailing byte", []byte{0}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ payload := append(bytes.Clone(frame), tc.trailing...)
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecZstd, payload)))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.ErrorContains(t, err, "trailing data")
+ })
+ }
+}
+
+func TestReadCompressedPuffinBlobRejectsTruncatedZstd(t *testing.T) {
+ frame := compressedBlobTestPayload(t, puffin.CompressionCodecZstd,
bytes.Repeat([]byte("hello"), 100))
+ for n := 1; n < len(frame); n++ {
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecZstd, frame[:n])))
+ require.NoError(t, err)
+ _, err = r.ReadBlob(0)
+ require.Error(t, err, "truncated at %d of %d", n, len(frame))
+ }
+}
+
+func TestReadCompressedPuffinBlobZstdMultipleBlocksAndEmpty(t *testing.T) {
+ large := make([]byte, 300000)
+ for i := range large {
+ large[i] = byte((i * 31) ^ (i >> 7))
+ }
+ for _, tc := range []struct {
+ name string
+ frame, want []byte
+ }{
+ {"multiple blocks", compressedBlobTestPayload(t,
puffin.CompressionCodecZstd, large), large},
+ {"empty", []byte{0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x00, 0x01,
0x00, 0x00}, []byte{}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecZstd, tc.frame)))
+ require.NoError(t, err)
+ blob, err := r.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, tc.want, blob.Data)
+ })
+ }
+}
+
+func TestReadRejectsCompressedDeletionVectors(t *testing.T) {
+ for _, codec := range []string{puffin.CompressionCodecLZ4,
puffin.CompressionCodecZstd} {
+ t.Run(codec, func(t *testing.T) {
+ stored := compressedBlobTestPayload(t, codec,
[]byte("invalid compressed deletion vector"))
+ meta := puffin.BlobMetadata{
+ Type: puffin.BlobTypeDeletionVector,
+ Fields: []int32{},
+ SnapshotID: -1,
+ SequenceNumber: -1,
+ Offset: puffin.MagicSize,
+ Length: int64(len(stored)),
+ CompressionCodec: &codec,
+ }
+ reader, err :=
puffin.NewReader(bytes.NewReader(compressedBlobTestFileWithMetadata(t, stored,
meta)))
+ require.NoError(t, err)
+ _, err = reader.ReadBlob(0)
+ require.ErrorContains(t, err, "deletion-vector-v1 must
not be compressed")
+
+ reader, err =
puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t, codec, stored)))
+ require.NoError(t, err)
+ _, err = reader.ReadBlobByMetadata(meta)
+ require.ErrorContains(t, err, "deletion-vector-v1 must
not be compressed")
+ })
+ }
+}
+
+func TestReadSmallZstdBlobWithLargeWindow(t *testing.T) {
+ raw := bytes.Repeat([]byte("abc"), 100)
+ encoder, err := zstd.NewWriter(nil, zstd.WithWindowSize(8<<20),
zstd.WithSingleSegment(false))
+ require.NoError(t, err)
+ frame := encoder.EncodeAll(raw, nil)
+ encoder.Close()
+ var initial zstd.Header
+ require.NoError(t, initial.Decode(frame))
+ require.False(t, initial.SingleSegment)
+ // Set a valid 8 MiB window descriptor while retaining the original
blocks.
+ frame[5] = 0x68
+ var header zstd.Header
+ require.NoError(t, header.Decode(frame))
+ require.True(t, header.HasFCS)
+ require.Equal(t, uint64(8<<20), header.WindowSize)
+ require.Equal(t, uint64(len(raw)), header.FrameContentSize)
+ r, err := puffin.NewReader(bytes.NewReader(compressedBlobTestFile(t,
puffin.CompressionCodecZstd, frame)), puffin.WithMaxBlobSize(1024))
+ require.NoError(t, err)
+ blob, err := r.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, raw, blob.Data)
+}
diff --git a/puffin/puffin_reader.go b/puffin/puffin_reader.go
index 0a5b0987f..e7db8731a 100644
--- a/puffin/puffin_reader.go
+++ b/puffin/puffin_reader.go
@@ -77,7 +77,8 @@ type ReaderOption func(*Reader)
// WithMaxBlobSize sets the maximum blob size allowed when reading.
// This prevents OOM attacks from malicious files with huge blob lengths.
-// Default is DefaultMaxBlobSize (256 MB).
+// Default is DefaultMaxBlobSize (256 MB). Zstd frames also have a window
+// limit equal to the larger of this size and 8 MiB.
func WithMaxBlobSize(size int64) ReaderOption {
return func(r *Reader) {
r.maxBlobSize = size
@@ -365,10 +366,18 @@ func (r *Reader) readBlobData(meta BlobMetadata) ([]byte,
error) {
if meta.Type == "" {
return nil, errors.New("puffin: cannot read blob: type is
required")
}
+ if meta.Type == BlobTypeDeletionVector && meta.CompressionCodec != nil {
+ return nil, errors.New("puffin: deletion-vector-v1 must not be
compressed")
+ }
- // Check for compressed blob (unsupported)
- if meta.CompressionCodec != nil && *meta.CompressionCodec != "" {
- return nil, fmt.Errorf("puffin: cannot read blob: compression
%q not supported", *meta.CompressionCodec)
+ // Reject unknown codecs before issuing range reads. An omitted codec
+ // means uncompressed; an explicitly empty codec is invalid.
+ codec := ""
+ if meta.CompressionCodec != nil {
+ codec = *meta.CompressionCodec
+ if codec != CompressionCodecLZ4 && codec !=
CompressionCodecZstd {
+ return nil, fmt.Errorf("puffin: cannot read blob:
compression %q not supported", codec)
+ }
}
// Validate offset/length
@@ -381,6 +390,9 @@ func (r *Reader) readBlobData(meta BlobMetadata) ([]byte,
error) {
if _, err := r.r.ReadAt(data, meta.Offset); err != nil {
return nil, fmt.Errorf("puffin: read blob data: %w", err)
}
+ if codec != "" {
+ return readCompressedBlob(data, codec, r.maxBlobSize)
+ }
return data, nil
}
@@ -502,6 +514,8 @@ func (r *Reader) validateBlobs(blobs []BlobMetadata,
footerStart int64) error {
// "<type> requires X to be -1, got N" so both sides share a
// grep-able canonical message.
if blob.Type == BlobTypeDeletionVector {
+ // Leave compression validation to ReadBlob and the
deletion-vector
+ // reader, which reports ErrInvalidDeletionVector to
its callers.
if blob.SnapshotID != -1 {
return fmt.Errorf("puffin: blob %d: %s requires
snapshot-id to be -1, got %d",
i, BlobTypeDeletionVector,
blob.SnapshotID)
diff --git a/puffin/puffin_test.go b/puffin/puffin_test.go
index aad597296..510f586d9 100644
--- a/puffin/puffin_test.go
+++ b/puffin/puffin_test.go
@@ -1247,20 +1247,29 @@ func TestReadJavaUncompressedMetricData(t *testing.T) {
assert.Len(t, blob1.Data, 83)
}
-// TestReadJavaCompressedMetricData verifies our reader correctly rejects
-// the zstd-compressed fixture since compression is not yet supported.
+// TestReadJavaCompressedMetricData verifies that Java-produced zstd blobs
+// decode to the same bytes as the canonical uncompressed Puffin fixture.
func TestReadJavaCompressedMetricData(t *testing.T) {
- data := readFixture(t, "sample-metric-data-compressed-zstd.bin")
- r, err := puffin.NewReader(bytes.NewReader(data))
+ compressed := readFixture(t, "sample-metric-data-compressed-zstd.bin")
+ r, err := puffin.NewReader(bytes.NewReader(compressed))
+ require.NoError(t, err)
+
+ plain := readFixture(t, "sample-metric-data-uncompressed.bin")
+ uncompressed, err := puffin.NewReader(bytes.NewReader(plain))
require.NoError(t, err)
- // Metadata should be readable (compression is per-blob, not footer)
blobs := r.Blobs()
require.Len(t, blobs, 2)
+ for i, meta := range blobs {
+ require.NotNil(t, meta.CompressionCodec)
+ assert.Equal(t, "zstd", *meta.CompressionCodec)
- // But reading blob data should fail due to compression
- _, err = r.ReadBlob(0)
- assert.ErrorContains(t, err, "compression")
+ got, err := r.ReadBlob(i)
+ require.NoError(t, err)
+ want, err := uncompressed.ReadBlob(i)
+ require.NoError(t, err)
+ assert.Equal(t, want.Data, got.Data)
+ }
}
// TestWriterBitIdenticalWithJava verifies that our writer produces