This is an automated email from the ASF dual-hosted git repository.

laskoviymishka 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 7cf023352 fix(literals): reject oversized fixed-width values (#1585)
7cf023352 is described below

commit 7cf02335229fc54636f3e2a7ba6b86563f19593f
Author: Minh Vu <[email protected]>
AuthorDate: Fri Jul 31 16:20:33 2026 +0200

    fix(literals): reject oversized fixed-width values (#1585)
    
    ## What changed
    
    Continue padding shortened fixed-width bounds for compatibility, but
    reject inputs larger than the declared fixed width with
    `ErrInvalidBinSerialization`.
    
    ## Why
    
    The previous `len != width` branch copied every mismatched value into a
    width-sized buffer. Oversized values were silently truncated into a
    different valid value, which could corrupt bounds used for pruning.
    
    Regression coverage includes exact, shortened, empty, one-byte
    oversized, and substantially oversized inputs, with expected and actual
    lengths asserted for oversize errors.
    
    ## Testing
    
    - `go test .`
    - `go vet .`
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 errors.go                               |  1 +
 literals.go                             |  5 ++-
 literals_test.go                        | 37 ++++++++++++++++
 table/evaluators.go                     | 29 +++++++++++--
 table/evaluators_invalid_bounds_test.go | 77 +++++++++++++++++++++++++++++++++
 5 files changed, 144 insertions(+), 5 deletions(-)

diff --git a/errors.go b/errors.go
index 45e03770e..1d031c6fe 100644
--- a/errors.go
+++ b/errors.go
@@ -34,6 +34,7 @@ var (
        ErrBadCast                 = errors.New("could not cast value")
        ErrBadLiteral              = errors.New("invalid literal value")
        ErrInvalidBinSerialization = errors.New("invalid binary serialization")
+       ErrInvalidFixedLength      = errors.New("invalid fixed literal length")
        ErrResolve                 = errors.New("cannot resolve type")
        // ErrBBoxNotSerializable is returned when marshaling a geospatial bbox
        // predicate to REST expression JSON: bbox predicates exist only for 
local
diff --git a/literals.go b/literals.go
index 5a0a068de..4ee361425 100644
--- a/literals.go
+++ b/literals.go
@@ -193,13 +193,16 @@ func LiteralFromBytes(typ Type, data []byte) (Literal, 
error) {
 
                return GeoLiteral{val: data, typ: typ}, nil
        case FixedType:
-               if len(data) != t.Len() {
+               if len(data) < t.Len() {
                        // looks like some writers will write a prefix of the 
fixed length
                        // for lower/upper bounds instead of the full length. 
so let's pad
                        // it out to the full length if unpacking a fixed 
length literal
                        padded := make([]byte, t.Len())
                        copy(padded, data)
                        data = padded
+               } else if len(data) > t.Len() {
+                       return nil, fmt.Errorf("%w: %w: fixed[%d] value has %d 
bytes",
+                               ErrInvalidBinSerialization, 
ErrInvalidFixedLength, t.Len(), len(data))
                }
                var v FixedLiteral
                err := v.UnmarshalBinary(data)
diff --git a/literals_test.go b/literals_test.go
index e0030092f..acccd31ab 100644
--- a/literals_test.go
+++ b/literals_test.go
@@ -1231,6 +1231,43 @@ func TestUnmarshalBinary(t *testing.T) {
        }
 }
 
+func TestLiteralFromBytesFixedWidth(t *testing.T) {
+       t.Parallel()
+
+       tests := []struct {
+               name        string
+               data        []byte
+               want        iceberg.FixedLiteral
+               errContains string
+               oversized   bool
+       }{
+               {name: "exact", data: []byte{1, 2, 3, 4}, want: 
iceberg.FixedLiteral{1, 2, 3, 4}},
+               {name: "short", data: []byte{1, 2, 3}, want: 
iceberg.FixedLiteral{1, 2, 3, 0}},
+               {name: "zero length", data: []byte{}, want: 
iceberg.FixedLiteral{0, 0, 0, 0}},
+               {name: "nil", data: nil, errContains: "invalid binary 
serialization"},
+               {name: "one byte oversized", data: []byte{1, 2, 3, 4, 5}, 
errContains: "fixed[4] value has 5 bytes", oversized: true},
+               {name: "large oversized", data: make([]byte, 100), errContains: 
"fixed[4] value has 100 bytes", oversized: true},
+       }
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       literal, err := 
iceberg.LiteralFromBytes(iceberg.FixedTypeOf(4), test.data)
+                       if test.errContains != "" {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidBinSerialization)
+                               if test.oversized {
+                                       require.ErrorIs(t, err, 
iceberg.ErrInvalidFixedLength)
+                               }
+                               require.ErrorContains(t, err, test.errContains)
+
+                               return
+                       }
+
+                       require.NoError(t, err)
+                       assert.Equal(t, test.want, literal)
+               })
+       }
+}
+
 func TestRoundTripLiteralBinary(t *testing.T) {
        tests := []struct {
                typ    iceberg.Type
diff --git a/table/evaluators.go b/table/evaluators.go
index 03a13dbb8..da34bae67 100644
--- a/table/evaluators.go
+++ b/table/evaluators.go
@@ -19,6 +19,7 @@ package table
 
 import (
        "encoding"
+       "errors"
        "fmt"
        "math"
        "slices"
@@ -74,7 +75,12 @@ func (m *manifestEvalVisitor) Eval(manifest 
iceberg.ManifestFile) (bool, error)
                partitionFilter: m.partitionFilter,
        }
 
-       return iceberg.VisitExpr(ev.partitionFilter, &ev)
+       result, err := iceberg.VisitExpr(ev.partitionFilter, &ev)
+       if errors.Is(err, iceberg.ErrInvalidFixedLength) {
+               return rowsMightMatch, nil
+       }
+
+       return result, err
 }
 
 func removeBoundCmp[T iceberg.LiteralType](bound iceberg.Literal, vals 
[]iceberg.Literal, cmpToDelete int) []iceberg.Literal {
@@ -785,7 +791,12 @@ func (m *inclusiveMetricsEval) TestRowGroup(rgmeta 
*metadata.RowGroupMetaData, c
                }
        }
 
-       return iceberg.VisitExpr(m.expr, m)
+       result, err := iceberg.VisitExpr(m.expr, m)
+       if errors.Is(err, iceberg.ErrInvalidFixedLength) {
+               return rowsMightMatch, nil
+       }
+
+       return result, err
 }
 
 func (m *inclusiveMetricsEval) Eval(file iceberg.DataFile) (bool, error) {
@@ -803,7 +814,12 @@ func (m *inclusiveMetricsEval) Eval(file iceberg.DataFile) 
(bool, error) {
        ev.nanCounts = file.NaNValueCounts()
        ev.lowerBounds, ev.upperBounds = file.LowerBoundValues(), 
file.UpperBoundValues()
 
-       return iceberg.VisitExpr(m.expr, &ev)
+       result, err := iceberg.VisitExpr(m.expr, &ev)
+       if errors.Is(err, iceberg.ErrInvalidFixedLength) {
+               return rowsMightMatch, nil
+       }
+
+       return result, err
 }
 
 func (m *inclusiveMetricsEval) mayContainNull(fieldID int) bool {
@@ -1311,7 +1327,12 @@ func (m *strictMetricsEval) Eval(file iceberg.DataFile) 
(bool, error) {
        ev.nanCounts = file.NaNValueCounts()
        ev.lowerBounds, ev.upperBounds = file.LowerBoundValues(), 
file.UpperBoundValues()
 
-       return iceberg.VisitExpr(m.expr, &ev)
+       result, err := iceberg.VisitExpr(m.expr, &ev)
+       if errors.Is(err, iceberg.ErrInvalidFixedLength) {
+               return rowsMightNotMatch, nil
+       }
+
+       return result, err
 }
 
 func (m *strictMetricsEval) VisitUnbound(iceberg.UnboundPredicate) bool {
diff --git a/table/evaluators_invalid_bounds_test.go 
b/table/evaluators_invalid_bounds_test.go
new file mode 100644
index 000000000..bcd121dd6
--- /dev/null
+++ b/table/evaluators_invalid_bounds_test.go
@@ -0,0 +1,77 @@
+// 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 table
+
+import (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/require"
+)
+
+func TestMalformedFixedBoundsAreConservative(t *testing.T) {
+       schema := iceberg.NewSchema(1, iceberg.NestedField{
+               ID: 1, Name: "value", Type: iceberg.FixedTypeOf(4),
+       })
+       expr := iceberg.EqualTo(iceberg.Reference("value"), []byte{1, 2, 3, 4})
+       malformed := []byte{1, 2, 3, 4, 5}
+
+       t.Run("manifest evaluator keeps the manifest", func(t *testing.T) {
+               spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+                       SourceIDs: []int{1}, FieldID: 1000, Name: "value", 
Transform: iceberg.IdentityTransform{},
+               })
+               eval, err := newManifestEvaluator(spec, schema, expr, true)
+               require.NoError(t, err)
+
+               manifest := iceberg.NewManifestFile(2, "manifest.avro", 1, 0, 
1).
+                       AddedFiles(1).
+                       Partitions([]iceberg.FieldSummary{{LowerBound: 
&malformed, UpperBound: &malformed}}).
+                       Build()
+               matches, err := eval(manifest)
+               require.NoError(t, err)
+               require.True(t, matches)
+       })
+
+       builder, err := iceberg.NewDataFileBuilder(
+               iceberg.NewPartitionSpec(), iceberg.EntryContentData, 
"file.parquet",
+               iceberg.ParquetFile, nil, nil, nil, 1, 1,
+       )
+       require.NoError(t, err)
+       dataFile := builder.
+               ValueCounts(map[int]int64{1: 1}).
+               NullValueCounts(map[int]int64{1: 0}).
+               LowerBoundValues(map[int][]byte{1: malformed}).
+               UpperBoundValues(map[int][]byte{1: malformed}).
+               Build()
+
+       t.Run("inclusive evaluator keeps the file", func(t *testing.T) {
+               eval, err := newInclusiveMetricsEvaluator(schema, expr, true, 
false)
+               require.NoError(t, err)
+               matches, err := eval(dataFile)
+               require.NoError(t, err)
+               require.True(t, matches)
+       })
+
+       t.Run("strict evaluator does not prove a match", func(t *testing.T) {
+               eval, err := newStrictMetricsEvaluator(schema, expr, true, 
false)
+               require.NoError(t, err)
+               matches, err := eval(dataFile)
+               require.NoError(t, err)
+               require.False(t, matches)
+       })
+}

Reply via email to