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

pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4bedf4979e GH-49740: [C++][Python] Fix casts to view types leaving 
null variadic buffers (#50166)
4bedf4979e is described below

commit 4bedf4979e2d726b7e4560ecb0429e145426757c
Author: fenfeng9 <[email protected]>
AuthorDate: Wed Jul 1 16:08:18 2026 +0800

    GH-49740: [C++][Python] Fix casts to view types leaving null variadic 
buffers (#50166)
    
    ### Rationale for this change
    
      Casting to `binary_view` or `string_view` could leave a null variadic 
buffer slot when all values were inline. This could happen for casts from 
`binary`, `large_binary`, `string`, `large_string`, and `fixed_size_binary`.
    
      The C Data Interface exporter reads every variadic buffer to get its 
size. Because of that, exporting such an array could crash, for example through 
PyArrow `_export_to_c`.
    
    Validation also passed for these arrays. For all-inline view arrays, 
validation never needed to read an out-of-line data buffer.
    
    ### What changes are included in this PR?
    
    This PR fixes the cast kernels so all-inline view arrays do not keep a null 
variadic buffer slot.
    
    It also makes validation reject null variadic buffer slots, and makes C 
Data export return an error instead of crashing.
    
    C++ and Python regression tests cover the cast, validation, and export 
paths.
    
    ### Are these changes tested?
    
    Yes.
    
    ### Are there any user-facing changes?
    
    No.
    
    **This PR contains a "Critical Fix"** Exporting an all-inline view array 
through the C Data Interface could crash the process while using only public 
APIs.
    
    * GitHub Issue: #49740
    
    Authored-by: fenfeng9 <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 cpp/src/arrow/array/array_binary_test.cc           | 10 +++
 cpp/src/arrow/array/validate.cc                    |  6 ++
 cpp/src/arrow/c/bridge.cc                          |  5 ++
 cpp/src/arrow/c/bridge_test.cc                     | 59 +++++++++++++++
 .../arrow/compute/kernels/scalar_cast_string.cc    | 14 ++--
 cpp/src/arrow/compute/kernels/scalar_cast_test.cc  | 87 ++++++++++++++++++++--
 dev/archery/archery/integration/datagen.py         | 36 ++++++++-
 python/pyarrow/tests/test_cffi.py                  | 33 ++++++++
 8 files changed, 235 insertions(+), 15 deletions(-)

diff --git a/cpp/src/arrow/array/array_binary_test.cc 
b/cpp/src/arrow/array/array_binary_test.cc
index 04391be0ac..70245906f8 100644
--- a/cpp/src/arrow/array/array_binary_test.cc
+++ b/cpp/src/arrow/array/array_binary_test.cc
@@ -403,6 +403,16 @@ TEST(StringViewArray, Validate) {
                                   }),
               Ok());
 
+  // Variadic buffer slots, when present, must contain real buffers.
+  EXPECT_THAT(MakeBinaryViewArray({nullptr},
+                                  {
+                                      util::ToInlineBinaryView("hello"),
+                                      util::ToInlineBinaryView("world"),
+                                  }),
+              Raises(StatusCode::Invalid,
+                     ::testing::HasSubstr("null variadic buffer at buffer 
index #2 "
+                                          "(variadic buffer index #0)")));
+
   // non-inline views are expected to reference only buffers managed by the 
array
   EXPECT_THAT(
       MakeBinaryViewArray(
diff --git a/cpp/src/arrow/array/validate.cc b/cpp/src/arrow/array/validate.cc
index c1d96375bf..16bc9187af 100644
--- a/cpp/src/arrow/array/validate.cc
+++ b/cpp/src/arrow/array/validate.cc
@@ -504,6 +504,12 @@ struct ValidateArrayImpl {
                              : *layout.variadic_spec;
 
       if (buffer == nullptr) {
+        if (layout.variadic_spec && i >= 
static_cast<int>(layout.buffers.size())) {
+          return Status::Invalid("Array of type ", type.ToString(),
+                                 " has a null variadic buffer at buffer index 
#", i,
+                                 " (variadic buffer index #", i - 
layout.buffers.size(),
+                                 ")");
+        }
         continue;
       }
       int64_t min_buffer_size = 0;
diff --git a/cpp/src/arrow/c/bridge.cc b/cpp/src/arrow/c/bridge.cc
index 82e74098d2..4391d4cbc2 100644
--- a/cpp/src/arrow/c/bridge.cc
+++ b/cpp/src/arrow/c/bridge.cc
@@ -608,6 +608,11 @@ struct ArrayExporter {
       export_.variadic_buffer_sizes_.resize(variadic_buffers.size());
       size_t i = 0;
       for (const auto& buf : variadic_buffers) {
+        if (buf == nullptr) {
+          return Status::Invalid("Cannot export array of type ", 
data->type->ToString(),
+                                 ": null variadic buffer at buffer index #", i 
+ 2,
+                                 " (variadic buffer index #", i, ")");
+        }
         export_.variadic_buffer_sizes_[i++] = buf->size();
       }
       export_.buffers_.back() = export_.variadic_buffer_sizes_.data();
diff --git a/cpp/src/arrow/c/bridge_test.cc b/cpp/src/arrow/c/bridge_test.cc
index cb204806f9..4372732675 100644
--- a/cpp/src/arrow/c/bridge_test.cc
+++ b/cpp/src/arrow/c/bridge_test.cc
@@ -930,6 +930,28 @@ TEST_F(TestArrayExport, PrimitiveSliced) {
   TestPrimitive(factory);
 }
 
+TEST_F(TestArrayExport, RejectNullVariadicBuffers) {
+  // GH-49740: _export_to_c segmentation fault for binary_view array.
+  for (const auto& type : {binary_view(), utf8_view()}) {
+    auto arr =
+        MakeArray(ArrayData::Make(type, /*length=*/2,
+                                  {nullptr,
+                                   
Buffer::FromVector(std::vector<BinaryViewType::c_type>{
+                                       util::ToInlineBinaryView("hello"),
+                                       util::ToInlineBinaryView("world"),
+                                   }),
+                                   nullptr}));
+
+    struct ArrowArray c_export;
+    EXPECT_RAISES_WITH_MESSAGE_THAT(
+        Invalid,
+        ::testing::HasSubstr(
+            "Cannot export array of type " + type->ToString() +
+            ": null variadic buffer at buffer index #2 (variadic buffer index 
#0)"),
+        ExportArray(*arr, &c_export));
+  }
+}
+
 constexpr std::string_view binary_view_buffer_content0 = "12345foo bar baz 
quux",
                            binary_view_buffer_content1 = 
"BinaryViewMultipleBuffers";
 
@@ -2996,6 +3018,43 @@ TEST_F(TestArrayImport, String) {
   CheckImport(ArrayFromJSON(large_binary(), "[]"));
 }
 
+TEST_F(TestArrayImport, NullVariadicBuffers) {
+  // The C Data Interface allows null variadic buffer pointers with size 0.
+  // Import normalizes them to non-null zero-size buffers in Arrow C++.
+  std::vector<BinaryViewType::c_type> views = {
+      util::ToInlineBinaryView("hello"),
+      util::ToInlineBinaryView("world"),
+  };
+  constexpr int64_t null_variadic_buffer_sizes[] = {0};
+  const void* null_variadic_buffer[] = {
+      nullptr,
+      views.data(),
+      nullptr,
+      null_variadic_buffer_sizes,
+  };
+
+  for (const auto& type : {binary_view(), utf8_view()}) {
+    FillStringViewLike(/*length=*/2, /*null_count=*/0, /*offset=*/0, 
null_variadic_buffer,
+                       /*data_buffer_count=*/1);
+
+    ArrayReleaseCallback cb(&c_struct_);
+    ASSERT_OK_AND_ASSIGN(auto array, ImportArray(&c_struct_, type));
+    ASSERT_TRUE(ArrowArrayIsReleased(&c_struct_));
+    Reset();
+
+    ASSERT_OK(array->ValidateFull());
+    AssertArraysEqual(*ArrayFromJSON(type, R"(["hello", "world"])"), *array,
+                      /*verbose=*/true);
+
+    ASSERT_EQ(array->data()->buffers.size(), 3);
+    ASSERT_NE(array->data()->buffers[2], nullptr);
+    ASSERT_EQ(array->data()->buffers[2]->size(), 0);
+    cb.AssertNotCalled();
+    array.reset();
+    cb.AssertCalled();
+  }
+}
+
 TEST_F(TestArrayImport, StringWithOffset) {
   FillStringLike(3, 0, 1, string_buffers_no_nulls1);
   CheckImport(ArrayFromJSON(utf8(), R"(["", "bar", "quux"])"));
diff --git a/cpp/src/arrow/compute/kernels/scalar_cast_string.cc 
b/cpp/src/arrow/compute/kernels/scalar_cast_string.cc
index 4d0aa943ed..52b79c8d45 100644
--- a/cpp/src/arrow/compute/kernels/scalar_cast_string.cc
+++ b/cpp/src/arrow/compute/kernels/scalar_cast_string.cc
@@ -438,8 +438,7 @@ BinaryToBinaryCastExec(KernelContext* ctx, const ExecSpan& 
batch, ExecResult* ou
 
   auto* out_views = output->GetMutableValues<BinaryViewType::c_type>(1);
 
-  // If all entries are inline, we can drop the extra data buffer for
-  // large strings in output->buffers[2].
+  // If all entries are inline, there are no variadic data buffers to expose.
   bool all_entries_are_inline = true;
   VisitSetBitRunsVoid(
       validity, output->offset, output->length,
@@ -463,8 +462,9 @@ BinaryToBinaryCastExec(KernelContext* ctx, const ExecSpan& 
batch, ExecResult* ou
         }
       });
   if (all_entries_are_inline) {
-    output->buffers[2] = nullptr;
+    output->buffers.resize(2);
   }
+
   return Status::OK();
 }
 
@@ -508,11 +508,15 @@ BinaryToBinaryCastExec(KernelContext* ctx, const 
ExecSpan& batch, ExecResult* ou
 
   const int32_t fixed_size_width = input.type->byte_width();
   const int64_t total_length = input.offset + input.length;
+  // Values of width <= BinaryViewType::kInlineSize are stored inline in the
+  // views, so the output only needs a variadic data buffer for larger widths.
+  const bool values_are_inline = fixed_size_width <= 
BinaryViewType::kInlineSize;
+  const size_t num_buffers = values_are_inline ? 2 : 3;
 
   ArrayData* output = out->array_data().get();
   DCHECK_EQ(output->length, input.length);
   output->offset = input.offset;
-  output->buffers.resize(3);
+  output->buffers.resize(num_buffers);
   output->SetNullCount(input.null_count);
   // Share the validity bitmap buffer
   output->buffers[0] = input.GetBuffer(0);
@@ -539,7 +543,7 @@ BinaryToBinaryCastExec(KernelContext* ctx, const ExecSpan& 
batch, ExecResult* ou
   }
 
   // Inline string and non-inline string loops
-  if (fixed_size_width <= BinaryViewType::kInlineSize) {
+  if (values_are_inline) {
     int32_t data_offset = static_cast<int32_t>(input.offset) * 
fixed_size_width;
     for (int64_t i = 0; i < input.length; i++) {
       auto& out_view = out_views[i];
diff --git a/cpp/src/arrow/compute/kernels/scalar_cast_test.cc 
b/cpp/src/arrow/compute/kernels/scalar_cast_test.cc
index 4ff58040e0..51e6ca534c 100644
--- a/cpp/src/arrow/compute/kernels/scalar_cast_test.cc
+++ b/cpp/src/arrow/compute/kernels/scalar_cast_test.cc
@@ -3307,9 +3307,12 @@ TEST(Cast, BinaryToString) {
     // N.B. null buffer is not always the same if input sliced
     AssertBufferSame(*invalid_utf8, *strings, 0);
 
-    // ARROW-16757: we no longer zero copy, but the contents are equal
-    ASSERT_NE(invalid_utf8->data()->buffers[1].get(), 
strings->data()->buffers[2].get());
-    if (!is_binary_view_like(*string_type)) {
+    if (is_binary_view_like(*string_type)) {
+      ASSERT_EQ(strings->data()->buffers.size(), 2);
+    } else {
+      // ARROW-16757: we no longer zero copy, but the contents are equal
+      ASSERT_NE(invalid_utf8->data()->buffers[1].get(),
+                strings->data()->buffers[2].get());
       
ASSERT_TRUE(invalid_utf8->data()->buffers[1]->Equals(*strings->data()->buffers[2]));
     }
   }
@@ -3349,9 +3352,12 @@ TEST(Cast, BinaryOrStringToBinary) {
     // N.B. null buffer is not always the same if input sliced
     AssertBufferSame(*invalid_utf8, *strings, 0);
 
-    // ARROW-16757: we no longer zero copy, but the contents are equal
-    ASSERT_NE(invalid_utf8->data()->buffers[1].get(), 
strings->data()->buffers[2].get());
-    if (!is_binary_view_like(*to_type)) {
+    if (is_binary_view_like(*to_type)) {
+      ASSERT_EQ(strings->data()->buffers.size(), 2);
+    } else {
+      // ARROW-16757: we no longer zero copy, but the contents are equal
+      ASSERT_NE(invalid_utf8->data()->buffers[1].get(),
+                strings->data()->buffers[2].get());
       
ASSERT_TRUE(invalid_utf8->data()->buffers[1]->Equals(*strings->data()->buffers[2]));
     }
 
@@ -3361,6 +3367,75 @@ TEST(Cast, BinaryOrStringToBinary) {
   }
 }
 
+TEST(Cast, BinaryOrStringToView) {
+  // GH-49740: when all values were inline, the cast left a null variadic
+  // buffer slot behind and exporting to the C data interface crashed.
+  const std::vector<std::pair<std::string, int64_t>> cases = {
+      // Empty inputs are handled before the cast kernel runs.
+      {"[]", 2},
+      {R"(["a", null, "e"])", 2},
+      {"[null, null]", 2},
+      {R"(["aaaaaaaaaaaaa", null, "eeeeeeeeeeeee"])", 3},
+      {R"(["a", null, "aaaaaaaaaaaaa"])", 3},
+  };
+
+  for (auto from_type : {binary(), large_binary(), utf8(), large_utf8()}) {
+    for (auto to_type : {binary_view(), utf8_view()}) {
+      for (const auto& [json, expected_num_buffers] : cases) {
+        auto input = ArrayFromJSON(from_type, json);
+        auto expected = ArrayFromJSON(to_type, json);
+
+        ASSERT_OK_AND_ASSIGN(auto casted, Cast(*input, to_type));
+        ValidateOutput(*casted);
+        AssertArraysEqual(*expected, *casted);
+
+        ASSERT_EQ(casted->data()->buffers.size(), expected_num_buffers)
+            << "from: " << from_type->ToString() << ", to: " << 
to_type->ToString()
+            << ", values: " << json;
+        if (expected_num_buffers == 3) {
+          ASSERT_NE(casted->data()->buffers[2], nullptr)
+              << "from: " << from_type->ToString() << ", to: " << 
to_type->ToString()
+              << ", values: " << json;
+        }
+      }
+    }
+  }
+}
+
+TEST(Cast, FixedSizeBinaryToView) {
+  // Fixed-size binary uses a separate cast kernel with the same GH-49740
+  // issue. Cover non-empty widths on both sides of the BinaryView inline 
limit.
+  const std::vector<std::tuple<std::shared_ptr<DataType>, std::string, 
int64_t>> cases = {
+      // Empty inputs are handled before the cast kernel runs.
+      {fixed_size_binary(1), "[]", 2},
+      {fixed_size_binary(13), "[]", 2},
+      {fixed_size_binary(1), R"(["a", null, "e"])", 2},
+      {fixed_size_binary(1), "[null, null]", 2},
+      {fixed_size_binary(12), R"(["aaaaaaaaaaaa", null])", 2},
+      {fixed_size_binary(13), R"(["aaaaaaaaaaaaa", null, "eeeeeeeeeeeee"])", 
3},
+  };
+
+  for (const auto& [from_type, json, expected_num_buffers] : cases) {
+    for (auto to_type : {binary_view(), utf8_view()}) {
+      auto input = ArrayFromJSON(from_type, json);
+      auto expected = ArrayFromJSON(to_type, json);
+
+      ASSERT_OK_AND_ASSIGN(auto casted, Cast(*input, to_type));
+      ValidateOutput(*casted);
+      AssertArraysEqual(*expected, *casted);
+
+      ASSERT_EQ(casted->data()->buffers.size(), expected_num_buffers)
+          << "from: " << from_type->ToString() << ", to: " << 
to_type->ToString()
+          << ", values: " << json;
+      if (expected_num_buffers == 3) {
+        ASSERT_NE(casted->data()->buffers[2], nullptr)
+            << "from: " << from_type->ToString() << ", to: " << 
to_type->ToString()
+            << ", values: " << json;
+      }
+    }
+  }
+}
+
 TEST(Cast, StringToString) {
   for (auto from_type : {utf8(), utf8_view(), large_utf8()}) {
     for (auto to_type : {utf8(), utf8_view(), large_utf8()}) {
diff --git a/dev/archery/archery/integration/datagen.py 
b/dev/archery/archery/integration/datagen.py
index 231436e5ed..ed50919dfc 100644
--- a/dev/archery/archery/integration/datagen.py
+++ b/dev/archery/archery/integration/datagen.py
@@ -144,6 +144,7 @@ class NullField(PrimitiveField):
 
 TEST_INT_MAX = 2 ** 31 - 1
 TEST_INT_MIN = ~TEST_INT_MAX
+BINARY_VIEW_INLINE_SIZE = 12
 
 
 class IntegerField(PrimitiveField):
@@ -625,14 +626,19 @@ class StringField(BinaryField):
     def _get_type(self):
         return OrderedDict([('name', 'utf8')])
 
+    def _random_string_size(self):
+        return 7
+
+    def _random_value(self):
+        return tobytes(random_utf8(self._random_string_size()))
+
     def generate_column(self, size, name=None):
-        K = 7
         is_valid = self._make_is_valid(size)
         values = []
 
         for i in range(size):
             if is_valid[i]:
-                values.append(tobytes(random_utf8(K)))
+                values.append(self._random_value())
             else:
                 values.append(b"")
 
@@ -671,6 +677,14 @@ class BinaryViewField(BinaryField):
         return OrderedDict([('name', 'binaryview')])
 
 
+class InlineBinaryViewField(BinaryViewField):
+    # Generate only inline values, leaving no variadic data buffers.
+
+    def _random_sizes(self, size):
+        return np.random.randint(0, BINARY_VIEW_INLINE_SIZE + 1, size=size,
+                                 dtype=np.int32)
+
+
 class StringViewField(StringField):
 
     @property
@@ -681,6 +695,19 @@ class StringViewField(StringField):
         return OrderedDict([('name', 'utf8view')])
 
 
+class InlineStringViewField(StringViewField):
+
+    def _random_string_size(self):
+        # The test alphabet contains up to 3-byte UTF-8 code points, so four
+        # characters fit in the 12-byte inline representation.
+        return 4
+
+    def _random_value(self):
+        value = super()._random_value()
+        assert len(value) <= BINARY_VIEW_INLINE_SIZE
+        return value
+
+
 class Schema(object):
 
     def __init__(self, fields, metadata=None):
@@ -771,14 +798,13 @@ class BinaryViewColumn(PrimitiveColumn):
         # a small default data buffer size is used so we can exercise
         # arrays with multiple data buffers with small data sets
         DEFAULT_BUFFER_SIZE = 32
-        INLINE_SIZE = 12
 
         for i, v in enumerate(self.values):
             if not self.is_valid[i]:
                 v = b''
             assert isinstance(v, bytes)
 
-            if len(v) <= INLINE_SIZE:
+            if len(v) <= BINARY_VIEW_INLINE_SIZE:
                 # Append an inline view, skip data buffer management.
                 views.append(OrderedDict([
                     ('SIZE', len(v)),
@@ -1784,6 +1810,8 @@ def generate_binary_view_case():
     fields = [
         BinaryViewField('bv'),
         StringViewField('sv'),
+        InlineBinaryViewField('bv_inline'),
+        InlineStringViewField('sv_inline'),
     ]
     batch_sizes = [0, 7, 256]
     return _generate_file("binary_view", fields, batch_sizes)
diff --git a/python/pyarrow/tests/test_cffi.py 
b/python/pyarrow/tests/test_cffi.py
index 481c387d53..0d9fd72459 100644
--- a/python/pyarrow/tests/test_cffi.py
+++ b/python/pyarrow/tests/test_cffi.py
@@ -234,6 +234,39 @@ def test_export_import_array():
     )
 
 
+@needs_cffi
+def test_export_cast_binary_view_all_inline():
+    # GH-49740: _export_to_c segmentation fault for binary_view array.
+    c_schema = ffi.new("struct ArrowSchema*")
+    ptr_schema = int(ffi.cast("uintptr_t", c_schema))
+    c_array = ffi.new("struct ArrowArray*")
+    ptr_array = int(ffi.cast("uintptr_t", c_array))
+
+    gc.collect()  # Make sure no Arrow data dangles in a ref cycle
+    old_allocated = pa.total_allocated_bytes()
+
+    # The cast used to leave a null variadic buffer slot behind when all
+    # values were inline.
+    arr = pa.array([b"a", None, b"e"], type=pa.binary()).cast(pa.binary_view())
+    arr.validate(full=True)
+    py_value = arr.to_pylist()
+    arr._export_to_c(ptr_array, ptr_schema)
+
+    assert c_array.length == 3
+    # validity, views and the appended variadic buffer sizes
+    assert c_array.n_buffers == 3
+
+    del arr
+    arr_new = pa.Array._import_from_c(ptr_array, ptr_schema)
+    assert arr_new.to_pylist() == py_value
+    assert arr_new.type == pa.binary_view()
+    del arr_new
+    assert pa.total_allocated_bytes() == old_allocated
+    # Now released
+    with assert_schema_released:
+        pa.Array._import_from_c(ptr_array, ptr_schema)
+
+
 @needs_cffi
 def test_export_import_device_array():
     check_export_import_array(

Reply via email to