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

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


The following commit(s) were added to refs/heads/main by this push:
     new c87638f4a9 fix(ffi): make FFI struct fields private to close Drop 
soundness hole (#10431)
c87638f4a9 is described below

commit c87638f4a977c289395725bb329cf33fb4e8fc44
Author: Aditya Mishra <[email protected]>
AuthorDate: Thu Aug 13 18:53:00 2026 +0530

    fix(ffi): make FFI struct fields private to close Drop soundness hole 
(#10431)
    
    # Which issue does this PR close?
    
    - Closes #10429.
    - Closes #10253.
    
    # Rationale for this change
    
    `FFI_ArrowArray`, `FFI_ArrowSchema`, and `FFI_ArrowArrayStream` had all
    of their fields `pub`. Several of those fields carry invariants that the
    `Drop` impl and the import path rely on, so safe code could set them to
    values that trigger undefined behavior with no `unsafe` block:
    
    - Setting `FFI_ArrowSchema::format` or `name` to a pointer that did not
    come from `CString::into_raw` causes UB when the release callback frees
    it with `CString::from_raw`.
    - Setting `FFI_ArrowArray::buffers` to an invalid pointer causes UB when
    it is dereferenced with `ptr::read_unaligned` on import.
    - Setting the `release` fn pointer (or the stream's `get_schema` /
    `get_next` / `get_last_error` fn pointers) to a bogus function causes UB
    when it is invoked on drop or import.
    
    An earlier revision of this PR made only `release` and `private_data`
    private and left the data fields `pub`, on the assumption that the data
    fields were harmless. That was wrong: as noted in review, the
    pointer-carrying data fields are UB vectors too. Any write to these
    fields has to be treated as unsafe.
    
    # What changes are included in this PR?
    
    - All fields on `FFI_ArrowArray`, `FFI_ArrowSchema`, and
    `FFI_ArrowArrayStream` are now private.
    - Reads are unchanged: every field already has a typed getter, so
    consumers keep full read access.
    - The wrap-release use case from #9771 keeps working through the
    existing `unsafe` setters `set_release` and `set_private_data`, which
    swap in a new callback and private data and return the old values so the
    caller can chain into the original release. No other field needs a write
    path (the only fields any external consumer writes are `release` and
    `private_data`).
    - `#[repr(C)]` is unchanged, so the C Data Interface layout and ABI are
    identical.
    
    A separate soundness issue with `FFI_ArrowSchema::with_metadata` /
    `with_name` on foreign schemas was raised in review. It has a different
    root cause and changes the safe/unsafe surface of those methods, so it
    will be handled in its own issue and PR rather than bundled here.
    
    # Are these changes tested?
    
    Yes. Each struct has a `test_wrap_release_callback` test covering the
    swap-and-restore path, and all three pass under Miri with no leak,
    use-after-free, or double-free. The existing FFI roundtrip tests also
    pass under Miri. The full workspace builds and tests with `--features
    ffi`.
    
    # Are there any user-facing changes?
    
    Yes, this is a breaking change. Code that read these fields directly
    must switch to the getters. Code that constructed these structs with a
    struct literal, or wrote fields directly, must use the safe constructors
    (`try_from`, `new`, `empty`) or, for `release` / `private_data`, the
    `unsafe` setters. The `#[repr(C)]` layout is unchanged.
    
    ---------
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-array/src/ffi_stream.rs |  98 +++++++++++++++++++++++++++++++++++++---
 arrow-data/src/ffi.rs         |  63 +++++++++++++++++++++-----
 arrow-schema/src/ffi.rs       | 101 +++++++++++++++++++++++++++++++++++++-----
 3 files changed, 233 insertions(+), 29 deletions(-)

diff --git a/arrow-array/src/ffi_stream.rs b/arrow-array/src/ffi_stream.rs
index aeb7ca0918..ac79e10eb2 100644
--- a/arrow-array/src/ffi_stream.rs
+++ b/arrow-array/src/ffi_stream.rs
@@ -96,17 +96,18 @@ const ENOSYS: i32 = 38;
 #[repr(C)]
 #[derive(Debug)]
 pub struct FFI_ArrowArrayStream {
+    // Fields are intentionally private so safety guarantees can be upheld via
+    // explicit unsafe functions.
     /// C function to get schema from the stream
-    pub get_schema:
-        Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut 
FFI_ArrowSchema) -> c_int>,
+    get_schema: Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut 
FFI_ArrowSchema) -> c_int>,
     /// C function to get next array from the stream
-    pub get_next: Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut 
FFI_ArrowArray) -> c_int>,
+    get_next: Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut 
FFI_ArrowArray) -> c_int>,
     /// C function to get the error from last operation on the stream
-    pub get_last_error: Option<unsafe extern "C" fn(arg1: *mut Self) -> *const 
c_char>,
+    get_last_error: Option<unsafe extern "C" fn(arg1: *mut Self) -> *const 
c_char>,
     /// C function to release the stream
-    pub release: Option<unsafe extern "C" fn(arg1: *mut Self)>,
-    /// Private data used by the stream
-    pub private_data: *mut c_void,
+    release: Option<unsafe extern "C" fn(arg1: *mut Self)>,
+    /// Private data used by the stream, owned by the release callback.
+    private_data: *mut c_void,
 }
 
 unsafe impl Send for FFI_ArrowArrayStream {}
@@ -212,6 +213,45 @@ impl FFI_ArrowArrayStream {
             private_data: std::ptr::null_mut(),
         }
     }
+
+    /// Returns the producer-provided release callback, if any.
+    pub fn release(&self) -> Option<unsafe extern "C" fn(arg1: *mut Self)> {
+        self.release
+    }
+
+    /// Returns the opaque producer-provided private data pointer.
+    pub fn private_data(&self) -> *mut c_void {
+        self.private_data
+    }
+
+    /// Replaces the release callback, returning the previous one.
+    ///
+    /// Lets a consumer wrap release: save the old callback, install its own, 
and
+    /// chain back on drop. See 
<https://github.com/apache/arrow-rs/issues/9771>.
+    ///
+    /// # Safety
+    ///
+    /// [`Drop`] calls this callback with a pointer to `self`. The new callback
+    /// must correctly release this stream (usually by chaining to the returned
+    /// one) and must match the [`FFI_ArrowArrayStream::private_data`] it 
reads.
+    /// A wrong callback is undefined behavior on drop.
+    pub unsafe fn set_release(
+        &mut self,
+        release: Option<unsafe extern "C" fn(arg1: *mut Self)>,
+    ) -> Option<unsafe extern "C" fn(arg1: *mut Self)> {
+        std::mem::replace(&mut self.release, release)
+    }
+
+    /// Replaces the private data pointer, returning the previous one.
+    ///
+    /// # Safety
+    ///
+    /// The old pointer is returned without being freed; the caller owns it 
from
+    /// here. The new pointer must match what the current
+    /// [`FFI_ArrowArrayStream::release`] callback expects.
+    pub unsafe fn set_private_data(&mut self, private_data: *mut c_void) -> 
*mut c_void {
+        std::mem::replace(&mut self.private_data, private_data)
+    }
 }
 
 struct ExportedArrayStream {
@@ -564,4 +604,48 @@ mod tests {
 
         Ok(())
     }
+
+    // A consumer wraps the release callback with its own, then chains back to
+    // the original on drop. This is the same wrap-release pattern the
+    // release/private_data accessors exist for (#9771).
+    static STREAM_WRAPPER_RAN: std::sync::atomic::AtomicBool =
+        std::sync::atomic::AtomicBool::new(false);
+
+    struct StreamWrapperData {
+        original_release: Option<unsafe extern "C" fn(*mut 
FFI_ArrowArrayStream)>,
+        original_private_data: *mut c_void,
+    }
+
+    unsafe extern "C" fn wrapping_release(stream: *mut FFI_ArrowArrayStream) {
+        use std::sync::atomic::Ordering;
+        let stream = unsafe { &mut *stream };
+        let data = unsafe { Box::from_raw(stream.private_data() as *mut 
StreamWrapperData) };
+        STREAM_WRAPPER_RAN.store(true, Ordering::SeqCst);
+        unsafe { stream.set_release(data.original_release) };
+        unsafe { stream.set_private_data(data.original_private_data) };
+        if let Some(release) = stream.release() {
+            unsafe { release(stream) };
+        }
+    }
+
+    #[test]
+    fn test_wrap_release_callback() {
+        use std::sync::atomic::Ordering;
+
+        let batch_reader = Box::new(TestRecordBatchReader::new(
+            Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, 
true)])),
+            Box::new(std::iter::empty()),
+        ));
+        let mut stream = FFI_ArrowArrayStream::new(batch_reader);
+
+        let data = Box::new(StreamWrapperData {
+            original_release: stream.release(),
+            original_private_data: stream.private_data(),
+        });
+        unsafe { stream.set_release(Some(wrapping_release)) };
+        unsafe { stream.set_private_data(Box::into_raw(data) as *mut c_void) };
+
+        drop(stream); // runs wrapping_release, which chains to the original
+        assert!(STREAM_WRAPPER_RAN.load(Ordering::SeqCst));
+    }
 }
diff --git a/arrow-data/src/ffi.rs b/arrow-data/src/ffi.rs
index e00b6eea49..8506a8844d 100644
--- a/arrow-data/src/ffi.rs
+++ b/arrow-data/src/ffi.rs
@@ -37,31 +37,33 @@ use std::ffi::c_void;
 #[repr(C)]
 #[derive(Debug)]
 pub struct FFI_ArrowArray {
+    // Fields are intentionally private so safety guarantees can be upheld via
+    // explicit unsafe functions.
     /// Logical length of the array
-    pub length: i64,
+    length: i64,
     /// Number of null items in the array
-    pub null_count: i64,
+    null_count: i64,
     /// logical offset inside the array
-    pub offset: i64,
+    offset: i64,
     /// Number of physical buffers backing this array
-    pub n_buffers: i64,
+    n_buffers: i64,
     /// Number of children this array has
-    pub n_children: i64,
+    n_children: i64,
     /// C array of pointers to the start of each physical buffer backing this 
array
-    pub buffers: *mut *const c_void,
+    buffers: *mut *const c_void,
     /// C array of pointers to each child array of this array
-    pub children: *mut *mut FFI_ArrowArray,
+    children: *mut *mut FFI_ArrowArray,
     /// Pointer to the underlying array of dictionary values
-    pub dictionary: *mut FFI_ArrowArray,
-    /// Pointer to a producer-provided release callback
-    pub release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowArray)>,
+    dictionary: *mut FFI_ArrowArray,
+    /// Producer-provided release callback.
+    release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowArray)>,
     /// Opaque pointer to producer-provided private data
     /// When exported, this MUST contain everything that is owned by this 
array.
     /// For example, any buffer pointed to in `buffers` must be here, as well
     /// as the `buffers` pointer itself.
     /// In other words, everything in [FFI_ArrowArray] must be owned by
     /// `private_data` and can assume that they do not outlive `private_data`.
-    pub private_data: *mut c_void,
+    private_data: *mut c_void,
 }
 
 impl Drop for FFI_ArrowArray {
@@ -251,6 +253,45 @@ impl FFI_ArrowArray {
         }
     }
 
+    /// Returns the producer-provided release callback, if any.
+    pub fn release(&self) -> Option<unsafe extern "C" fn(arg1: *mut 
FFI_ArrowArray)> {
+        self.release
+    }
+
+    /// Returns the opaque producer-provided private data pointer.
+    pub fn private_data(&self) -> *mut c_void {
+        self.private_data
+    }
+
+    /// Replaces the release callback, returning the previous one.
+    ///
+    /// Lets a consumer wrap release: save the old callback, install its own, 
and
+    /// chain back on drop. See 
<https://github.com/apache/arrow-rs/issues/9771>.
+    ///
+    /// # Safety
+    ///
+    /// [`Drop`] calls this callback with a pointer to `self`. The new callback
+    /// must correctly release this array (usually by chaining to the returned
+    /// one) and must match the [`FFI_ArrowArray::private_data`] it reads. A
+    /// wrong callback is undefined behavior on drop.
+    pub unsafe fn set_release(
+        &mut self,
+        release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowArray)>,
+    ) -> Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowArray)> {
+        std::mem::replace(&mut self.release, release)
+    }
+
+    /// Replaces the private data pointer, returning the previous one.
+    ///
+    /// # Safety
+    ///
+    /// The old pointer is returned without being freed; the caller owns it 
from
+    /// here. The new pointer must match what the current
+    /// [`FFI_ArrowArray::release`] callback expects.
+    pub unsafe fn set_private_data(&mut self, private_data: *mut c_void) -> 
*mut c_void {
+        std::mem::replace(&mut self.private_data, private_data)
+    }
+
     /// the length of the array
     #[inline]
     pub fn len(&self) -> usize {
diff --git a/arrow-schema/src/ffi.rs b/arrow-schema/src/ffi.rs
index 6bff0c85df..557313ab85 100644
--- a/arrow-schema/src/ffi.rs
+++ b/arrow-schema/src/ffi.rs
@@ -74,25 +74,27 @@ bitflags! {
 #[repr(C)]
 #[derive(Debug)]
 pub struct FFI_ArrowSchema {
+    // Fields are intentionally private so safety guarantees can be upheld via
+    // explicit unsafe functions.
     /// Null-terminated, UTF8-encoded string describing the data type
-    pub format: *const c_char,
+    format: *const c_char,
     /// Null-terminated, UTF8-encoded string of the field or array name
-    pub name: *const c_char,
+    name: *const c_char,
     /// Binary string describing the type’s metadata
-    pub metadata: *const c_char,
+    metadata: *const c_char,
     /// A bitfield of flags enriching the type description
     /// Refer to [Arrow 
Flags](https://arrow.apache.org/docs/format/CDataInterface.html#c.ArrowSchema.flags)
-    pub flags: i64,
+    flags: i64,
     /// The number of children this type has
-    pub n_children: i64,
+    n_children: i64,
     /// C array of pointers to each child type of this type
-    pub children: *mut *mut FFI_ArrowSchema,
+    children: *mut *mut FFI_ArrowSchema,
     /// Pointer to the type of dictionary values
-    pub dictionary: *mut FFI_ArrowSchema,
-    /// Pointer to a producer-provided release callback
-    pub release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowSchema)>,
-    /// Opaque pointer to producer-provided private data
-    pub private_data: *mut c_void,
+    dictionary: *mut FFI_ArrowSchema,
+    /// Producer-provided release callback.
+    release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowSchema)>,
+    /// Opaque producer-provided private data, owned by the release callback.
+    private_data: *mut c_void,
 }
 
 struct SchemaPrivateData {
@@ -279,6 +281,45 @@ impl FFI_ArrowSchema {
         }
     }
 
+    /// Returns the producer-provided release callback, if any.
+    pub fn release(&self) -> Option<unsafe extern "C" fn(arg1: *mut 
FFI_ArrowSchema)> {
+        self.release
+    }
+
+    /// Returns the opaque producer-provided private data pointer.
+    pub fn private_data(&self) -> *mut c_void {
+        self.private_data
+    }
+
+    /// Replaces the release callback, returning the previous one.
+    ///
+    /// Lets a consumer wrap release: save the old callback, install its own, 
and
+    /// chain back on drop. See 
<https://github.com/apache/arrow-rs/issues/9771>.
+    ///
+    /// # Safety
+    ///
+    /// [`Drop`] calls this callback with a pointer to `self`. The new callback
+    /// must correctly release this schema (usually by chaining to the returned
+    /// one) and must match the [`FFI_ArrowSchema::private_data`] it reads. A
+    /// wrong callback is undefined behavior on drop.
+    pub unsafe fn set_release(
+        &mut self,
+        release: Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowSchema)>,
+    ) -> Option<unsafe extern "C" fn(arg1: *mut FFI_ArrowSchema)> {
+        std::mem::replace(&mut self.release, release)
+    }
+
+    /// Replaces the private data pointer, returning the previous one.
+    ///
+    /// # Safety
+    ///
+    /// The old pointer is returned without being freed; the caller owns it 
from
+    /// here. The new pointer must match what the current
+    /// [`FFI_ArrowSchema::release`] callback expects.
+    pub unsafe fn set_private_data(&mut self, private_data: *mut c_void) -> 
*mut c_void {
+        std::mem::replace(&mut self.private_data, private_data)
+    }
+
     /// Returns the format of this schema.
     ///
     /// # Panics
@@ -870,6 +911,7 @@ impl TryFrom<Schema> for FFI_ArrowSchema {
 mod tests {
     use super::*;
     use crate::Fields;
+    use std::sync::atomic::{AtomicBool, Ordering};
 
     fn round_trip_type(dtype: DataType) {
         let c_schema = FFI_ArrowSchema::try_from(&dtype).unwrap();
@@ -1042,4 +1084,41 @@ mod tests {
         let field = Field::try_from(&c_schema).unwrap();
         assert_eq!(field.name(), "");
     }
+
+    // A consumer wraps the release callback with its own, then chains back to
+    // the original on drop. This is the cross-thread use case from #9771 and 
is
+    // what the release/private_data accessors exist for.
+    static WRAPPER_RAN: AtomicBool = AtomicBool::new(false);
+
+    struct WrapperData {
+        original_release: Option<unsafe extern "C" fn(*mut FFI_ArrowSchema)>,
+        original_private_data: *mut c_void,
+    }
+
+    unsafe extern "C" fn wrapping_release(schema: *mut FFI_ArrowSchema) {
+        let schema = unsafe { &mut *schema };
+        let data = unsafe { Box::from_raw(schema.private_data() as *mut 
WrapperData) };
+        WRAPPER_RAN.store(true, Ordering::SeqCst);
+        // restore the originals, then let the original callback free 
everything
+        unsafe { schema.set_release(data.original_release) };
+        unsafe { schema.set_private_data(data.original_private_data) };
+        if let Some(release) = schema.release() {
+            unsafe { release(schema) };
+        }
+    }
+
+    #[test]
+    fn test_wrap_release_callback() {
+        let mut schema = FFI_ArrowSchema::try_from(&DataType::Int32).unwrap();
+
+        let data = Box::new(WrapperData {
+            original_release: schema.release(),
+            original_private_data: schema.private_data(),
+        });
+        unsafe { schema.set_release(Some(wrapping_release)) };
+        unsafe { schema.set_private_data(Box::into_raw(data) as *mut c_void) };
+
+        drop(schema); // runs wrapping_release, which chains to the original
+        assert!(WRAPPER_RAN.load(Ordering::SeqCst));
+    }
 }

Reply via email to