Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion adapters/core2sn/class.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ func AdaptDeprecatedEntryPoint(ep *core.DeprecatedEntryPoint) starknet.EntryPoin
func AdaptDeprecatedCairoClass(
class *core.DeprecatedCairoClass,
) (starknet.DeprecatedCairoClass, error) {
decompressedProgram, err := compression.Gzip64Decode(class.Program)
decompressedProgram, err := compression.Gzip64Decode(
class.Program, core.MaxDeprecatedClassProgramSize,
)
if err != nil {
return starknet.DeprecatedCairoClass{}, err
}
Expand Down
4 changes: 4 additions & 0 deletions core/class.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ var (

const minDeclaredClassSize = 8

// MaxDeprecatedClassProgramSize bounds the decompressed size of a deprecated
// Cairo 0 class program
const MaxDeprecatedClassProgramSize = 16 * db.Megabyte
Comment thread
rodrodros marked this conversation as resolved.

// Single felt identifying the number "0.1.0" as a short string
var SierraVersion010 felt.Felt = felt.Felt(
[4]uint64{
Expand Down
9 changes: 6 additions & 3 deletions core/class_hash.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"fmt"
"sync"

"github.com/NethermindEth/juno/core/crypto"
Expand All @@ -9,14 +10,16 @@
)

func deprecatedCairoClassHash(class *DeprecatedCairoClass) (felt.Felt, error) {
decompressedProgram, err := compression.Gzip64Decode(class.Program)
decompressedProgram, err := compression.Gzip64Decode(
class.Program, MaxDeprecatedClassProgramSize,
)
if err != nil {
return felt.Felt{}, err
return felt.Felt{}, fmt.Errorf("decompressing Cairo Zero class: %w", err)

Check warning on line 17 in core/class_hash.go

View check run for this annotation

Codecov / codecov/patch

core/class_hash.go#L17

Added line #L17 was not covered by tests
}
Comment thread
rodrodros marked this conversation as resolved.

program, err := unmarshalDeprecatedCairoProgram(decompressedProgram)
if err != nil {
return felt.Felt{}, err
return felt.Felt{}, fmt.Errorf("unmarshalling Cairo Zero class: %w", err)

Check warning on line 22 in core/class_hash.go

View check run for this annotation

Codecov / codecov/patch

core/class_hash.go#L22

Added line #L22 was not covered by tests
}

var (
Expand Down
2 changes: 1 addition & 1 deletion rpc/v10/transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2285,7 +2285,7 @@ func TestContractClassToGatewayPayload(t *testing.T) {
require.Equal(t, class.EntryPoints, decoded.EntryPoints)
require.Equal(t, class.ABI, decoded.ABI)

sierraJSON, err := compression.Gzip64Decode(decoded.SierraProgram)
sierraJSON, err := compression.Gzip64Decode(decoded.SierraProgram, compression.NoLimit)
require.NoError(t, err, "sierra_program must be gzip+base64 encoded")

var roundTripped []felt.Felt
Expand Down
5 changes: 4 additions & 1 deletion rpc/v8/class.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@
}
base64Program := string(program[1 : len(program)-1])

feederClass.DeprecatedCairo.Program, err = compression.Gzip64Decode(base64Program)
feederClass.DeprecatedCairo.Program, err = compression.Gzip64Decode(
base64Program,
core.MaxDeprecatedClassProgramSize,
)

Check warning on line 73 in rpc/v8/class.go

View check run for this annotation

Codecov / codecov/patch

rpc/v8/class.go#L70-L73

Added lines #L70 - L73 were not covered by tests
if err != nil {
return nil, err
}
Expand Down
5 changes: 4 additions & 1 deletion rpc/v9/class.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@
}
base64Program := string(program[1 : len(program)-1])

feederClass.DeprecatedCairo.Program, err = compression.Gzip64Decode(base64Program)
feederClass.DeprecatedCairo.Program, err = compression.Gzip64Decode(
base64Program,
core.MaxDeprecatedClassProgramSize,
)

Check warning on line 79 in rpc/v9/class.go

View check run for this annotation

Codecov / codecov/patch

rpc/v9/class.go#L76-L79

Added lines #L76 - L79 were not covered by tests
if err != nil {
return nil, err
}
Expand Down
29 changes: 27 additions & 2 deletions utils/compression/compression.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"sync"

"github.com/klauspost/compress/gzip"
Expand All @@ -27,6 +28,9 @@ const (
levelCount = maxLevel - minLevel + 1
)

// NoLimit disables the decompressed-size bound in Gzip64Decode.
const NoLimit int64 = math.MaxInt64

var ErrWriterNotAcquired = errors.New("using writer after release")

// gzipWriterPools holds one pool per compression level.
Expand Down Expand Up @@ -134,6 +138,7 @@ func GzipWriterLevel(dst io.Writer, level int) *Writer {
return writer
}

// Gzip64Encode encodes data with default compression
func Gzip64Encode(data []byte) (string, error) {
var compressedBuffer bytes.Buffer
gzipWriter := GzipWriter(&compressedBuffer)
Expand All @@ -147,7 +152,10 @@ func Gzip64Encode(data []byte) (string, error) {
return base64.StdEncoding.EncodeToString(compressedBuffer.Bytes()), nil
}

func Gzip64Decode(data string) ([]byte, error) {
// Gzip64Decode decompress data with a size limit of `maxDecompressedSize`.
// If decoded data turns out to be bigger an error is retured. Use
// [NoLimit] for unbounded decompression.
Comment thread
rodrodros marked this conversation as resolved.
func Gzip64Decode(data string, maxDecompressedSize int64) ([]byte, error) {
Comment thread
brbrr marked this conversation as resolved.
Comment thread
rodrodros marked this conversation as resolved.
decodedBytes, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, err
Expand All @@ -156,13 +164,30 @@ func Gzip64Decode(data string) ([]byte, error) {
if err != nil {
return nil, err
}
decompressedBytes, err := io.ReadAll(gzipReader)

// Read one byte more than the limit to differentiate between the decompressed
// size fitting (<= maxDecompressedSize) and overflowing (> maxDecompressedSize).
// The guard keeps NoLimit from overflowing.
readLimit := maxDecompressedSize
if readLimit < NoLimit {
readLimit++
}
Comment thread
rodrodros marked this conversation as resolved.

limited := io.LimitReader(gzipReader, readLimit)
decompressedBytes, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if int64(len(decompressedBytes)) > maxDecompressedSize {
return nil, fmt.Errorf(
"decompressed data exceeded the maximum byte size: %d", maxDecompressedSize,
)
}

err = gzipReader.Close()
if err != nil {
return nil, err
}
Comment thread
rodrodros marked this conversation as resolved.

return decompressedBytes, nil
}
2 changes: 1 addition & 1 deletion utils/compression/compression_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func BenchmarkGzip64Decode(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(size))
for b.Loop() {
if _, err := compression.Gzip64Decode(encoded); err != nil {
if _, err := compression.Gzip64Decode(encoded, compression.NoLimit); err != nil {
b.Fatal(err)
}
}
Expand Down
128 changes: 124 additions & 4 deletions utils/compression/compression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package compression_test

import (
"bytes"
"encoding/base64"
"errors"
"io"
"runtime"
Expand All @@ -24,16 +25,135 @@ func TestGzip64(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, expectedComBytes, comBytes)

decompBytes, err := compression.Gzip64Decode(comBytes)
decompBytes, err := compression.Gzip64Decode(comBytes, compression.NoLimit)
require.NoError(t, err)
assert.Equal(t, bytes, decompBytes)
}

func TestGzip64Decode(t *testing.T) {
const limit = 1024
tests := []struct {
name string
payload []byte
limit int64
wantErrContains string // empty means the payload is expected to round-trip
}{
{
name: "within limit",
payload: bytes.Repeat([]byte("a"), limit/2),
limit: limit,
},
{
name: "unbounded limit",
payload: []byte{0},
limit: compression.NoLimit,
},
{
name: "empty payload",
payload: []byte{},
limit: limit,
},
{
// The budget is inclusive: a payload that exactly fills it is valid.
name: "exactly at limit",
payload: bytes.Repeat([]byte("a"), limit),
limit: limit,
},
{
// One byte over is the smallest overflow the +1 read must catch.
name: "one byte over limit",
payload: bytes.Repeat([]byte("a"), limit+1),
limit: limit,
wantErrContains: "decompressed data exceeded the maximum byte size:",
},
{
name: "zero limit rejects any content",
payload: []byte("x"),
limit: 0,
wantErrContains: "decompressed data exceeded the maximum byte size:",
},
Comment thread
rodrodros marked this conversation as resolved.
{
name: "zero limit accepts empty payload",
payload: []byte{},
limit: 0,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
encoded, err := compression.Gzip64Encode(test.payload)
require.NoError(t, err)

decoded, err := compression.Gzip64Decode(encoded, test.limit)
if test.wantErrContains != "" {
assert.ErrorContains(t, err, test.wantErrContains)
assert.Nil(t, decoded)
return
}

require.NoError(t, err)
assert.Equal(t, test.payload, decoded)
})
}
}

// A compression bomb — a few KiB of input inflating to 64 MiB — must be rejected
// cheaply.
func TestGzip64DecodeCompressionBomb(t *testing.T) {
const limit = 1024
bomb := make([]byte, 64*1024*1024) // zeros compress ~1000:1
encoded, err := compression.Gzip64Encode(bomb)
require.NoError(t, err)

var before, after runtime.MemStats
runtime.ReadMemStats(&before)
decoded, err := compression.Gzip64Decode(encoded, limit)
runtime.ReadMemStats(&after)

assert.ErrorContains(t, err, "decompressed data exceeded the maximum byte size:")
assert.Nil(t, decoded)
assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(8*1024*1024),
"rejecting an over-limit stream must not materialise the decompressed payload")
}

// Check stream corruption is properly shown.
func TestGzip64DecodeCorruptStream(t *testing.T) {
const limit = 1024
payload := bytes.Repeat([]byte("a"), limit)
encoded, err := compression.Gzip64Encode(payload)
require.NoError(t, err)
raw, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)

// Corrupt the gzip footer
t.Run("corrupt checksum at exactly the limit", func(t *testing.T) {
corrupted := bytes.Clone(raw)
corrupted[len(corrupted)-1] ^= 0xff // corrupt the footer

decoded, err := compression.Gzip64Decode(
base64.StdEncoding.EncodeToString(corrupted), limit,
)
assert.ErrorIs(t, err, gzip.ErrChecksum)
assert.Nil(t, decoded)
})

t.Run("truncated stream", func(t *testing.T) {
decoded, err := compression.Gzip64Decode(
// remove the footer and part of the data.
base64.StdEncoding.EncodeToString(raw[:len(raw)/2]),
limit,
)

assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Nil(t, decoded)
})
}

func FuzzGzip64(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
compressed, err := compression.Gzip64Encode(data)
require.NoError(t, err)
decompressed, err := compression.Gzip64Decode(compressed)
decompressed, err := compression.Gzip64Decode(compressed, compression.NoLimit)
require.NoError(t, err)
assert.Equal(t, data, decompressed)
})
Expand All @@ -50,7 +170,7 @@ func TestGzip64EncodeAcrossSuccessiveCalls(t *testing.T) {

encoded, err := compression.Gzip64Encode(payload)
require.NoError(t, err)
decoded, err := compression.Gzip64Decode(encoded)
decoded, err := compression.Gzip64Decode(encoded, compression.NoLimit)
require.NoError(t, err)
assert.Equal(t, payload, decoded)
})
Expand Down Expand Up @@ -204,7 +324,7 @@ func TestGzip64EncodeConcurrent(t *testing.T) {
payload := bytes.Repeat([]byte{byte('a' + i)}, chunk*(i+1))
encoded, err := compression.Gzip64Encode(payload)
assert.NoError(t, err)
decoded, err := compression.Gzip64Decode(encoded)
decoded, err := compression.Gzip64Decode(encoded, compression.NoLimit)
assert.NoError(t, err)
assert.Equal(t, payload, decoded)
})
Expand Down
Loading