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 70fa5bcf21 Fix FFI import of zero-length `Utf8`/`Binary` arrays at a
non-zero offset (#10916)
70fa5bcf21 is described below
commit 70fa5bcf21924c5a9ea34ee2df846ffae43f2bc6
Author: Andrea Bozzo <[email protected]>
AuthorDate: Sun Aug 30 23:49:40 2026 +0200
Fix FFI import of zero-length `Utf8`/`Binary` arrays at a non-zero offset
(#10916)
> **Note (edited after review):** this description originally justified
the
> `is_empty()` guard by the C Data Interface's allowance for a null
pointer on a
> zero-sized buffer. That was wrong — the columnar format requires
`length + 1`
> offsets, so an empty array's offsets buffer is one element, not 0
bytes. The
> guard actually dates to #5964 and exists to protect
> `test_empty_string_with_non_zero_offset` (added in #5741), whose
length-0 array
> carries a lone offset of `123` over an empty values buffer. Thanks to
@Jefffrey
> for catching it. Corrected below and in the code comment (af93e15);
the change
> itself is unaffected.
# Which issue does this PR close?
Closes #10910.
# Rationale for this change
`ImportedArrowArray::buffer_len` short-circuited the values buffer
(buffer 2) of
`Utf8`/`Binary` and `LargeUtf8`/`LargeBinary` arrays to `0` whenever
`ArrowArray.length` was `0`, without taking `ArrowArray.offset` into
account. The
offsets, however, are read *at* the array offset, so importing a
zero-length array
at a non-zero offset produced an `ArrayData` whose values buffer is
shorter than
its own offsets describe. `from_ffi` builds it with `new_unchecked`, so
nothing is
reported at import time; the inconsistency only surfaces once something
validates
the result:
```
Invalid argument error: First offset 1 of Utf8 is larger than values length 0
```
The shape is not reachable through arrow-rs's own slicing — `slice(n,
0)` normalises
the offset back to 0 — so it takes a producer on the other side of the
interface.
pyarrow produces one readily with `pa.array(["x", "aa", "bb"]).slice(1,
0)`, and that
is how this was found.
The `is_empty()` guard came in with #5964 ("Fix FFI array offset
handling"), which
changed the values length from `end - start` to `end`. It exists because
the sole
offset of a zero-length array is unconstrained and producers put
arbitrary values
there — see the existing `test_empty_string_with_non_zero_offset` (added
in #5741),
whose lone offset is `123` over an empty values buffer, and which `end`
alone would
size at 123 bytes. That reasoning only holds at offset 0: once the
offset is
non-zero, the offsets up to and including `offset` describe real
preceding elements
whose bytes the values buffer must still cover.
# What changes are included in this PR?
Narrow the guard to `offset == 0` in both arms, so a zero-length array
at a non-zero
offset takes the same path as a non-empty one and reads its values
length from the
last offset of the window.
The stale safety comment above the `- 1` is corrected too: it claimed
the array is
non-empty, whereas the invariant that makes the index sound is `len +
offset >= 1`.
The list types are untouched: for `List`/`LargeList`/`Map`, buffer 2 is
child data
rather than a buffer, so `buffer_len` never reaches these arms for them.
# Are these changes tested?
Yes — `tests_from_ffi::test_zero_length_bytes_at_non_zero_offset` sweeps
offsets
`0..4` over `Utf8`, `Binary`, `LargeUtf8` and `LargeBinary`, asserting
the round trip
passes `validate_full`, matches the exported `ArrayData`, and sizes the
values buffer
from the window's last offset.
I checked that it fails without the fix, and that it discriminates the
two arms
independently: reverting only the `i32` arm fails on `Utf8`, reverting
only the `i64`
arm fails on `LargeUtf8`, both with the error above. It does not
separately
discriminate `Binary` from `Utf8` (the loop stops at the first failure),
but each pair
shares a single match arm, so there is no distinct code path uncovered.
`cargo test -p arrow-array --features ffi` is green (771 unit + 205 doc
tests), as is
`--features ffi,force_validate`; `cargo fmt --check` and `cargo clippy
--all-targets`
are clean.
One thing I looked at while reviewing: whether widening the dereference
can now be
reached with a null offsets pointer. It cannot. `buffers()` resolves
buffer 1 before
buffer 2 (`map` is lazy and `collect::<Result<_>>` short-circuits), and
`buffer_len(1)`
is `(length + 1) * width`, never 0 — so a null offsets pointer always
fails at index 1
with `The external buffer at position 1 is null.` before `buffer_len(2)`
is called.
# Are there any user-facing changes?
No API changes. Zero-length `Utf8`/`Binary`/`LargeUtf8`/`LargeBinary`
arrays imported
at a non-zero offset now yield a valid `ArrayData` instead of one that
fails
validation. There is no breaking change.
---
arrow-array/src/ffi.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 61 insertions(+), 4 deletions(-)
diff --git a/arrow-array/src/ffi.rs b/arrow-array/src/ffi.rs
index 1957031c22..14235631a8 100644
--- a/arrow-array/src/ffi.rs
+++ b/arrow-array/src/ffi.rs
@@ -474,7 +474,15 @@ impl ImportedArrowArray<'_> {
length * (bits / 8)
}
(DataType::Utf8 | DataType::Binary, 2) => {
- if self.array.is_empty() {
+ // We can short circuit for empty arrays with offset 0 since
we know
+ // the values buffer must also be empty, and the single offset
present
+ // in the offsets buffer can be an arbitrary value from the
producer.
+ //
+ // If the array is empty yet has a non-zero offset, the C data
interface
+ // guarantees there are `length + offset` values encoded in
the buffer,
+ // so we must find the real size of the values buffer from the
offsets
+ // buffer.
+ if self.array.is_empty() && self.array.offset() == 0 {
return Ok(0);
}
@@ -485,12 +493,13 @@ impl ImportedArrowArray<'_> {
#[expect(clippy::cast_ptr_alignment)]
let offset_buffer = self.array.buffer(1).cast::<i32>();
// Safety: `len` is the byte length of the offset buffer;
dividing by `size_of::<i32>()`
- // gives the number of i32 elements. The `- 1` is safe because
the array is non-empty
- // (checked above), so the offset buffer has at least one
element.
+ // gives the number of i32 elements. The `- 1` is safe because
the offset buffer
+ // is always non-empty.
(unsafe { *offset_buffer.add(len / size_of::<i32>() - 1) }) as
usize
}
(DataType::LargeUtf8 | DataType::LargeBinary, 2) => {
- if self.array.is_empty() {
+ // See the note on the `Utf8` / `Binary` arm above.
+ if self.array.is_empty() && self.array.offset() == 0 {
return Ok(0);
}
@@ -1695,6 +1704,54 @@ mod tests_from_ffi {
test_round_trip(&imported_array.consume()?)
}
+ /// A zero-length `Utf8` / `Binary` array at a non-zero offset must
survive a
+ /// round trip: the length of the values buffer has to come from the last
offset
+ /// of the window, as it already does for non-empty arrays, rather than
being
+ /// short-circuited to 0 on the length alone.
+ ///
+ /// <https://github.com/apache/arrow-rs/issues/10910>
+ #[test]
+ fn test_zero_length_bytes_at_non_zero_offset() -> Result<()> {
+ // "x", "aa", "bb", viewed as zero elements starting at `offset`.
+ let small = Buffer::from_slice_ref([0i32, 1, 3, 5]);
+ let large = Buffer::from_slice_ref([0i64, 1, 3, 5]);
+ let values = Buffer::from(b"xaabb".as_slice());
+
+ for (data_type, offsets) in [
+ (DataType::Utf8, &small),
+ (DataType::Binary, &small),
+ (DataType::LargeUtf8, &large),
+ (DataType::LargeBinary, &large),
+ ] {
+ for offset in 0..4 {
+ let data = ArrayData::try_new(
+ data_type.clone(),
+ 0,
+ None,
+ offset,
+ vec![offsets.clone(), values.clone()],
+ vec![],
+ )?;
+
+ let array = FFI_ArrowArray::new(&data);
+ let schema = FFI_ArrowSchema::try_from(&data_type)?;
+ let imported = unsafe { from_ffi(array, &schema) }?;
+
+ // `from_ffi` builds the `ArrayData` with `new_unchecked`, so
an
+ // inconsistency only surfaces once something validates it.
+ imported.validate_full()?;
+ assert_eq!(imported.len(), 0);
+ assert_eq!(imported, data);
+
+ // The values buffer is sized from the last offset of the
window,
+ // not short-circuited to 0.
+ assert_eq!(imported.buffers()[1].len(), [0, 1, 3, 5][offset]);
+ }
+ }
+
+ Ok(())
+ }
+
fn roundtrip_string_array(array: StringArray) -> StringArray {
let data = array.into_data();