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

Reply via email to