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/arrow-go.git
The following commit(s) were added to refs/heads/main by this push:
new d80b252e fix(parquet/pqarrow): reject out-of-range INT96 timestamps
(#1128)
d80b252e is described below
commit d80b252e47ae0aac7ac669b610e83802a8968bd4
Author: Minh Vu <[email protected]>
AuthorDate: Tue Aug 18 18:56:03 2026 +0200
fix(parquet/pqarrow): reject out-of-range INT96 timestamps (#1128)
### Rationale for this change
transferInt96 converts Parquet INT96 values through time.Time.UnixNano
without checking the int64 nanosecond range. Very wide dates can
therefore wrap when read as Arrow timestamp[ns].
### What changes are included in this PR?
Add checked INT96 to nanosecond conversion, propagate conversion errors
through the Parquet reader, and normalize Int96.ToTime without
overflowing before the range check.
### Are these changes tested?
- `go test ./parquet/pqarrow -run
'Test(ArrowTimestampToImpalaTimestamp|ReadInt96RejectsOutOfRangeTimestamp|WriteArrowInt96)$'`
### Are there any user-facing changes?
In-range INT96 behavior is unchanged. Out-of-range INT96 timestamps now
return an error instead of being wrapped.
---
parquet/pqarrow/column_readers.go | 33 +++++---
parquet/pqarrow/encode_arrow_int96_test.go | 130 +++++++++++++++++++++++++++++
parquet/types.go | 35 +++++++-
parquet/types_test.go | 125 +++++++++++++++++++++++++++
4 files changed, 309 insertions(+), 14 deletions(-)
diff --git a/parquet/pqarrow/column_readers.go
b/parquet/pqarrow/column_readers.go
index 5757e863..1ab16bd7 100644
--- a/parquet/pqarrow/column_readers.go
+++ b/parquet/pqarrow/column_readers.go
@@ -662,7 +662,10 @@ func transferColumnData(rdr file.RecordReader, valueType
arrow.DataType, descr *
dt = valueType.(arrow.ExtensionType).StorageType()
}
- var data arrow.ArrayData
+ var (
+ data arrow.ArrayData
+ err error
+ )
switch dt.ID() {
case arrow.DICTIONARY:
return transferDictionary(rdr, valueType, mem)
@@ -702,7 +705,10 @@ func transferColumnData(rdr file.RecordReader, valueType
arrow.DataType, descr *
data = transferZeroCopy(rdr, valueType)
case arrow.Nanosecond:
if descr.PhysicalType() == parquet.Types.Int96 {
- data = transferInt96(rdr, valueType)
+ data, err = transferInt96(rdr, valueType)
+ if err != nil {
+ return nil, err
+ }
} else {
data = transferZeroCopy(rdr, valueType)
}
@@ -887,28 +893,31 @@ func transferDate64(rdr file.RecordReader, dt
arrow.DataType) arrow.ArrayData {
}
// coerce int96 to nanosecond timestamp
-func transferInt96(rdr file.RecordReader, dt arrow.DataType) arrow.ArrayData {
+func transferInt96(rdr file.RecordReader, dt arrow.DataType) (arrow.ArrayData,
error) {
length := rdr.ValuesWritten()
values := parquet.Int96Traits.CastFromBytes(rdr.Values())
+ bitmap := rdr.ReleaseValidBits()
+ if bitmap != nil {
+ defer bitmap.Release()
+ }
+
data := make([]byte, arrow.Int64SizeBytes*length)
out := arrow.Int64Traits.CastFromBytes(data)
for idx, val := range values[:length] {
- if binary.LittleEndian.Uint32(val[8:]) == 0 {
- out[idx] = 0
- } else {
- out[idx] = val.ToTime().UnixNano()
+ if bitmap == nil || bitutil.BitIsSet(bitmap.Bytes(), idx) {
+ timestamp, err := val.ToTimestamp()
+ if err != nil {
+ return nil, fmt.Errorf("parquet INT96 timestamp
at index %d: %w", idx, err)
+ }
+ out[idx] = int64(timestamp)
}
}
- bitmap := rdr.ReleaseValidBits()
- if bitmap != nil {
- defer bitmap.Release()
- }
return array.NewData(dt, length, []*memory.Buffer{
bitmap, memory.NewBufferBytes(data),
- }, nil, int(rdr.NullCount()), 0)
+ }, nil, int(rdr.NullCount()), 0), nil
}
// convert physical integer storage of a decimal logical type to a decimal128
typed array
diff --git a/parquet/pqarrow/encode_arrow_int96_test.go
b/parquet/pqarrow/encode_arrow_int96_test.go
index 4e5cbf2d..7ca74d04 100644
--- a/parquet/pqarrow/encode_arrow_int96_test.go
+++ b/parquet/pqarrow/encode_arrow_int96_test.go
@@ -17,11 +17,19 @@
package pqarrow
import (
+ "bytes"
+ "context"
+ "encoding/binary"
"testing"
+ "time"
"github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/file"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestArrowTimestampToImpalaTimestamp(t *testing.T) {
@@ -49,3 +57,125 @@ func TestArrowTimestampToImpalaTimestamp(t *testing.T) {
})
}
}
+
+func TestReadInt96RejectsOutOfRangeTimestamp(t *testing.T) {
+ nanosPerDay := uint64(24 * time.Hour)
+ var epoch parquet.Int96
+ arrowTimestampToImpalaTimestamp(arrow.Nanosecond, 0, &epoch)
+ nanosecondsAtEndOfDay := epoch
+ binary.LittleEndian.PutUint64(nanosecondsAtEndOfDay[:8], nanosPerDay)
+ tests := []struct {
+ name string
+ corrupt parquet.Int96
+ }{
+ {
+ name: "zero value",
+ corrupt: parquet.NewInt96([3]uint32{0, 0, 0}),
+ },
+ {
+ name: "julian day out of range",
+ corrupt: parquet.NewInt96([3]uint32{0, 0, ^uint32(0)}),
+ },
+ {
+ name: "nanoseconds at end of day",
+ corrupt: nanosecondsAtEndOfDay,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := readCorruptInt96(t, tt.corrupt)
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func readCorruptInt96(t *testing.T, corrupt parquet.Int96) error {
+ t.Helper()
+ mem := memory.NewGoAllocator()
+ timestampType := &arrow.TimestampType{Unit: arrow.Nanosecond}
+ sc := arrow.NewSchema([]arrow.Field{{Name: "ts", Type: timestampType}},
nil)
+ builder := array.NewTimestampBuilder(mem, timestampType)
+ builder.Append(0)
+ record := array.NewRecordBatch(sc, []arrow.Array{builder.NewArray()}, 1)
+ builder.Release()
+ defer record.Release()
+
+ var buf bytes.Buffer
+ writer, err := NewFileWriter(
+ sc,
+ &buf,
+ parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(false),
+ parquet.WithEncodingFor("ts", parquet.Encodings.Plain),
+ ),
+ NewArrowWriterProperties(WithDeprecatedInt96Timestamps(true)),
+ )
+ require.NoError(t, err)
+ require.NoError(t, writer.Write(record))
+ require.NoError(t, writer.Close())
+
+ var valid parquet.Int96
+ arrowTimestampToImpalaTimestamp(arrow.Nanosecond, 0, &valid)
+ encoded := buf.Bytes()
+ idx := bytes.Index(encoded, valid[:])
+ require.GreaterOrEqual(t, idx, 0)
+ copy(encoded[idx:idx+parquet.Int96SizeBytes], corrupt[:])
+
+ fileReader, err := file.NewParquetReader(bytes.NewReader(encoded))
+ require.NoError(t, err)
+ defer fileReader.Close()
+
+ arrowReader, err := NewFileReader(fileReader, ArrowReadProperties{},
mem)
+ require.NoError(t, err)
+ columnReader, err := arrowReader.GetColumn(context.Background(), 0)
+ require.NoError(t, err)
+ defer columnReader.Release()
+
+ _, err = columnReader.NextBatch(1)
+ return err
+}
+
+func TestReadInt96SkipsNullPhysicalValues(t *testing.T) {
+ mem := memory.NewGoAllocator()
+ timestampType := &arrow.TimestampType{Unit: arrow.Nanosecond}
+ sc := arrow.NewSchema([]arrow.Field{{Name: "ts", Type: timestampType,
Nullable: true}}, nil)
+ builder := array.NewTimestampBuilder(mem, timestampType)
+ builder.AppendNull()
+ builder.Append(0)
+ record := array.NewRecordBatch(sc, []arrow.Array{builder.NewArray()}, 2)
+ builder.Release()
+ defer record.Release()
+
+ var buf bytes.Buffer
+ writer, err := NewFileWriter(
+ sc,
+ &buf,
+ parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(false),
+ parquet.WithEncodingFor("ts", parquet.Encodings.Plain),
+ ),
+ NewArrowWriterProperties(WithDeprecatedInt96Timestamps(true)),
+ )
+ require.NoError(t, err)
+ require.NoError(t, writer.Write(record))
+ require.NoError(t, writer.Close())
+
+ fileReader, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+ require.NoError(t, err)
+ defer fileReader.Close()
+
+ arrowReader, err := NewFileReader(fileReader, ArrowReadProperties{},
mem)
+ require.NoError(t, err)
+ columnReader, err := arrowReader.GetColumn(context.Background(), 0)
+ require.NoError(t, err)
+ defer columnReader.Release()
+
+ chunked, err := columnReader.NextBatch(2)
+ require.NoError(t, err)
+ defer chunked.Release()
+
+ values := chunked.Chunk(0).(*array.Timestamp)
+ assert.True(t, values.IsNull(0))
+ assert.Equal(t, arrow.Timestamp(0), values.Value(1))
+}
diff --git a/parquet/types.go b/parquet/types.go
index f8c904c8..c1bd4372 100644
--- a/parquet/types.go
+++ b/parquet/types.go
@@ -18,7 +18,9 @@ package parquet
import (
"encoding/binary"
+ "fmt"
"io"
+ "math"
"reflect"
"strings"
"time"
@@ -85,11 +87,40 @@ func (i96 Int96) ToTime() time.Time {
nanos := binary.LittleEndian.Uint64(i96[:8])
jdays := binary.LittleEndian.Uint32(i96[8:])
- nanos = (uint64(jdays)-uint64(julianUnixEpoch))*uint64(nanosPerDay) +
nanos
- t := time.Unix(0, int64(nanos))
+ days := int64(jdays) - julianUnixEpoch
+ seconds := days*86400 + int64(nanos/1_000_000_000)
+ t := time.Unix(seconds, int64(nanos%1_000_000_000))
return t.UTC()
}
+// ToTimestamp converts an Int96 value to an Arrow nanosecond timestamp.
+func (i96 Int96) ToTimestamp() (arrow.Timestamp, error) {
+ nanosOfDay := binary.LittleEndian.Uint64(i96[:8])
+ if nanosOfDay >= uint64(nanosPerDay) {
+ return 0, fmt.Errorf("%w: invalid INT96 nanoseconds of day:
%d", arrow.ErrInvalid, nanosOfDay)
+ }
+
+ days := int64(binary.LittleEndian.Uint32(i96[8:])) - julianUnixEpoch
+ maxDays := math.MaxInt64 / nanosPerDay
+ if days > maxDays || (days == maxDays && int64(nanosOfDay) >
math.MaxInt64%nanosPerDay) {
+ return 0, fmt.Errorf("%w: INT96 timestamp is outside the Arrow
nanosecond range", arrow.ErrInvalid)
+ }
+
+ minDays := math.MinInt64 / nanosPerDay
+ minRemainder := math.MinInt64 % nanosPerDay
+ minNanos := nanosPerDay + minRemainder
+ // Go division truncates toward zero, so the minimum timestamp can fall
on
+ // the day before minDays with a positive nanoseconds-of-day remainder.
+ if days < minDays {
+ if days != minDays-1 || int64(nanosOfDay) < minNanos {
+ return 0, fmt.Errorf("%w: INT96 timestamp is outside
the Arrow nanosecond range", arrow.ErrInvalid)
+ }
+ return arrow.Timestamp(math.MinInt64 + int64(nanosOfDay) -
minNanos), nil
+ }
+
+ return arrow.Timestamp(days*nanosPerDay + int64(nanosOfDay)), nil
+}
+
type int96Traits struct{}
func (int96Traits) BytesRequired(n int) int { return Int96SizeBytes * n }
diff --git a/parquet/types_test.go b/parquet/types_test.go
new file mode 100644
index 00000000..6946b887
--- /dev/null
+++ b/parquet/types_test.go
@@ -0,0 +1,125 @@
+// 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 parquet
+
+import (
+ "math"
+ "testing"
+ "time"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInt96ToTimestamp(t *testing.T) {
+ maxDays := math.MaxInt64 / nanosPerDay
+ maxNanos := math.MaxInt64 % nanosPerDay
+ minDays := math.MinInt64 / nanosPerDay
+ minNanos := nanosPerDay + math.MinInt64%nanosPerDay
+
+ tests := []struct {
+ name string
+ value Int96
+ want arrow.Timestamp
+ wantErr error
+ }{
+ {
+ name: "exact minimum",
+ value: newInt96Timestamp(minDays-1, minNanos),
+ want: arrow.Timestamp(math.MinInt64),
+ },
+ {
+ name: "below minimum",
+ value: newInt96Timestamp(minDays-1, minNanos-1),
+ wantErr: arrow.ErrInvalid,
+ },
+ {
+ name: "exact maximum",
+ value: newInt96Timestamp(maxDays, maxNanos),
+ want: arrow.Timestamp(math.MaxInt64),
+ },
+ {
+ name: "above maximum",
+ value: newInt96Timestamp(maxDays, maxNanos+1),
+ wantErr: arrow.ErrInvalid,
+ },
+ {
+ name: "last nanosecond of day",
+ value: newInt96Timestamp(0, nanosPerDay-1),
+ want: arrow.Timestamp(nanosPerDay - 1),
+ },
+ {
+ name: "nanoseconds at end of day",
+ value: newInt96Timestamp(0, nanosPerDay),
+ wantErr: arrow.ErrInvalid,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.value.ToTimestamp()
+ if tt.wantErr != nil {
+ require.ErrorIs(t, err, tt.wantErr)
+ return
+ }
+
+ require.NoError(t, err)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestInt96ToTime(t *testing.T) {
+ wideDate := time.Date(3000, time.January, 1, 0, 0, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ value Int96
+ want time.Time
+ }{
+ {
+ name: "unix epoch",
+ value: newInt96Timestamp(0, 0),
+ want: time.Unix(0, 0).UTC(),
+ },
+ {
+ name: "before unix epoch",
+ value: newInt96Timestamp(-1, nanosPerDay-1),
+ want: time.Unix(0, -1).UTC(),
+ },
+ {
+ name: "wide date",
+ value: newInt96Timestamp(wideDate.Unix()/86400, 0),
+ want: wideDate,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, tt.value.ToTime())
+ require.Equal(t, tt.want.String(), tt.value.String())
+ })
+ }
+}
+
+func newInt96Timestamp(days, nanosOfDay int64) Int96 {
+ nanos := uint64(nanosOfDay)
+ return NewInt96([3]uint32{
+ uint32(nanos),
+ uint32(nanos >> 32),
+ uint32(days + julianUnixEpoch),
+ })
+}