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 6a73da68 fix(array): validate JSON start offsets (#929)
6a73da68 is described below
commit 6a73da687e7830d6a82bac1cd06ed23f71e80387
Author: Minh Vu <[email protected]>
AuthorDate: Mon Jul 13 21:09:28 2026 +0200
fix(array): validate JSON start offsets (#929)
### What
Validate `WithStartOffset` before JSON decoding. Negative offsets are
rejected, seek errors are returned, and a seeker must report the requested
position. The same validation is shared by `FromJSON` and `RecordFromJSON`.
### Tests
- `go test ./arrow/array -count=1`
- `pre-commit run golangci-lint-full --all-files`
Regression coverage includes seek errors, incorrect positions, negative
offsets that must not call `Seek`, and a successful offset with the expected
decoded values.
---
arrow/array/util.go | 42 +++++++++++------
arrow/array/util_test.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 145 insertions(+), 15 deletions(-)
diff --git a/arrow/array/util.go b/arrow/array/util.go
index 136ed353..e369f788 100644
--- a/arrow/array/util.go
+++ b/arrow/array/util.go
@@ -74,6 +74,29 @@ func WithUseNumber() FromJSONOption {
}
}
+func seekJSONStartOffset(r io.Reader, offset int64) error {
+ if offset < 0 {
+ return fmt.Errorf("seek JSON start offset must be non-negative:
%d", offset)
+ }
+ if offset == 0 {
+ return nil
+ }
+
+ seeker, ok := r.(io.ReadSeeker)
+ if !ok {
+ return errors.New("using StartOffset option requires reader to
be a ReadSeeker, cannot seek")
+ }
+
+ pos, err := seeker.Seek(offset, io.SeekStart)
+ if err != nil {
+ return fmt.Errorf("seek JSON start offset %d: %w", offset, err)
+ }
+ if pos != offset {
+ return fmt.Errorf("seek JSON start offset: got %d, want %d",
pos, offset)
+ }
+ return nil
+}
+
// FromJSON creates an arrow.Array from a corresponding JSON stream and
defined data type. If the types in the
// json do not match the type provided, it will return errors. This is *not*
the integration test format
// and should not be used as such. This intended to be used by consumers more
similarly to the current exposing of
@@ -133,13 +156,8 @@ func FromJSON(mem memory.Allocator, dt arrow.DataType, r
io.Reader, opts ...From
o(&cfg)
}
- if cfg.startOffset != 0 {
- seeker, ok := r.(io.ReadSeeker)
- if !ok {
- return nil, 0, errors.New("using StartOffset option
requires reader to be a ReadSeeker, cannot seek")
- }
-
- seeker.Seek(cfg.startOffset, io.SeekStart)
+ if err = seekJSONStartOffset(r, cfg.startOffset); err != nil {
+ return nil, 0, err
}
bldr := NewBuilder(mem, dt)
@@ -219,14 +237,8 @@ func RecordFromJSON(mem memory.Allocator, schema
*arrow.Schema, r io.Reader, opt
o(&cfg)
}
- if cfg.startOffset != 0 {
- seeker, ok := r.(io.ReadSeeker)
- if !ok {
- return nil, 0, errors.New("using StartOffset option
requires reader to be a ReadSeeker, cannot seek")
- }
- if _, err := seeker.Seek(cfg.startOffset, io.SeekStart); err !=
nil {
- return nil, 0, fmt.Errorf("failed to seek to start
offset %d: %w", cfg.startOffset, err)
- }
+ if err := seekJSONStartOffset(r, cfg.startOffset); err != nil {
+ return nil, 0, err
}
if mem == nil {
diff --git a/arrow/array/util_test.go b/arrow/array/util_test.go
index eb3de6a8..8fd36be8 100644
--- a/arrow/array/util_test.go
+++ b/arrow/array/util_test.go
@@ -19,6 +19,7 @@ package array_test
import (
"bufio"
"bytes"
+ "errors"
"fmt"
"io"
"reflect"
@@ -103,6 +104,123 @@ func TestIntegerArrsJSON(t *testing.T) {
}
}
+type fromJSONSeekReader struct {
+ *bytes.Reader
+ seekFn func(offset int64, whence int) (int64, error)
+ seekCalled bool
+}
+
+func (r *fromJSONSeekReader) Seek(offset int64, whence int) (int64, error) {
+ r.seekCalled = true
+ if r.seekFn != nil {
+ return r.seekFn(offset, whence)
+ }
+ return r.Reader.Seek(offset, whence)
+}
+
+func TestFromJSONStartOffsetSeekValidation(t *testing.T) {
+ seekErr := errors.New("seek failed")
+ tests := []struct {
+ name string
+ reader *fromJSONSeekReader
+ offset int64
+ wantErr error
+ wantErrContains string
+ wantSeek bool
+ }{
+ {
+ name: "seek error",
+ offset: 1,
+ wantErr: seekErr,
+ wantSeek: true,
+ reader: &fromJSONSeekReader{
+ Reader: bytes.NewReader([]byte("[1]")),
+ seekFn: func(int64, int) (int64, error) {
return 0, seekErr },
+ },
+ },
+ {
+ name: "wrong position",
+ offset: 1,
+ wantErrContains: "got 2, want 1",
+ wantSeek: true,
+ reader: &fromJSONSeekReader{
+ Reader: bytes.NewReader([]byte("[1]")),
+ seekFn: func(offset int64, _ int) (int64,
error) { return offset + 1, nil },
+ },
+ },
+ {
+ name: "negative offset",
+ offset: -1,
+ wantErrContains: "non-negative",
+ reader: &fromJSONSeekReader{Reader:
bytes.NewReader([]byte("[1]"))},
+ },
+ {
+ name: "successful seek",
+ offset: 4,
+ wantSeek: true,
+ reader: &fromJSONSeekReader{Reader:
bytes.NewReader([]byte("skip[1, 2]"))},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ arr, _, err := array.FromJSON(memory.DefaultAllocator,
arrow.PrimitiveTypes.Int32, tt.reader,
+ array.WithStartOffset(tt.offset))
+ if tt.name == "successful seek" {
+ require.NoError(t, err)
+ defer arr.Release()
+ assert.Equal(t, 2, arr.Len())
+ assert.Equal(t, int32(1),
arr.(*array.Int32).Value(0))
+ assert.Equal(t, int32(2),
arr.(*array.Int32).Value(1))
+ assert.True(t, tt.reader.seekCalled)
+ return
+ }
+
+ require.Error(t, err)
+ if tt.wantErr != nil {
+ assert.ErrorIs(t, err, tt.wantErr)
+ } else if tt.wantErrContains != "" {
+ assert.ErrorContains(t, err, tt.wantErrContains)
+ }
+ assert.Equal(t, tt.wantSeek, tt.reader.seekCalled)
+ })
+ }
+}
+
+func TestRecordFromJSONStartOffsetSeekValidation(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ schema := arrow.NewSchema([]arrow.Field{{Name: "value", Type:
arrow.PrimitiveTypes.Int32}}, nil)
+ seekErr := errors.New("seek failed")
+
+ t.Run("seek error", func(t *testing.T) {
+ reader := &fromJSONSeekReader{
+ Reader: bytes.NewReader([]byte(`[{"value": 1}]`)),
+ seekFn: func(int64, int) (int64, error) { return 0,
seekErr },
+ }
+
+ record, _, err := array.RecordFromJSON(mem, schema, reader,
array.WithStartOffset(1))
+ require.ErrorIs(t, err, seekErr)
+ require.Nil(t, record)
+ require.True(t, reader.seekCalled)
+ })
+
+ t.Run("successful seek", func(t *testing.T) {
+ reader := &fromJSONSeekReader{Reader:
bytes.NewReader([]byte(`skip[{"value": 1}, {"value": 2}]`))}
+
+ record, _, err := array.RecordFromJSON(mem, schema, reader,
array.WithStartOffset(4))
+ require.NoError(t, err)
+ defer record.Release()
+
+ require.EqualValues(t, 2, record.NumRows())
+ values := record.Column(0).(*array.Int32)
+ require.Equal(t, int32(1), values.Value(0))
+ require.Equal(t, int32(2), values.Value(1))
+ require.True(t, reader.seekCalled)
+ })
+}
+
func TestStringsJSON(t *testing.T) {
tests := []struct {
jsonstring string