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

github-merge-queue[bot] 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 cac30c2d58 fix: Only require valid UTF-8 within the visible span of 
string arrays (#11323)
cac30c2d58 is described below

commit cac30c2d586bde9b3c13a3f1166ddba4fbf87cd1
Author: Neil Conway <[email protected]>
AuthorDate: Sun Oct 4 03:41:18 2026 +0000

    fix: Only require valid UTF-8 within the visible span of string arrays 
(#11323)
    
    # Which issue does this PR close?
    
    - N/A
    
    # Rationale for this change
    
    Arrow does not require bytes outside the visible span of the values
    buffer to be valid UTF-8. This rule was not consistently applied:
    
    * `ArrayData` checked for UTF-8 validity over the visible span, but
    * `StringArray::try_new` and `try_from_binary` checked for UTF-8
    validity of the entire values buffer, which is both inefficient for
    sliced arrays and also rejects otherwise valid inputs
    * `substring` assumed that the entire values buffer contained valid
    UTF-8 bytes, which was sound for inputs from the `StringArray` path but
    not for the `ArrayData` path
    * Binary to Utf8 casts (in `safe: true`) reserved enough space for the
    whole values buffer, not the visible span
    
    # What changes are included in this PR?
    
    * Both validators now check only the bytes between the first and last
    offset
    * `substring` reads only those bytes as a `&str`.
    * The `safe: true` fallback of the Binary to Utf8 cast only reserves
    space for the visible span, not the whole values buffer.
    * Improve some docs
    * Add unit tests
    
    # Are these changes tested?
    
    Yes; existing tests pass, new tests added.
    
    # Are there any user-facing changes?
    
    No.
    
    # AI usage
    
    Developed with Claude Code (Opus 5.5), reviewed with Codex (Astra 6). I
    revised and understand the resulting code.
---
 arrow-array/src/array/byte_array.rs   |  7 ++++++-
 arrow-array/src/array/string_array.rs | 23 +++++++++++++++++++++++
 arrow-array/src/types.rs              | 25 ++++++++++++++-----------
 arrow-cast/src/cast/mod.rs            | 28 ++++++++++++++++++++++++++++
 arrow-cast/src/cast/string.rs         |  8 +++++---
 arrow-data/src/data.rs                | 33 ++++++++++++++++++++++++++-------
 arrow-string/src/substring.rs         | 20 ++++++++++++++++----
 7 files changed, 118 insertions(+), 26 deletions(-)

diff --git a/arrow-array/src/array/byte_array.rs 
b/arrow-array/src/array/byte_array.rs
index cd0b9f6358..049d1f47cf 100644
--- a/arrow-array/src/array/byte_array.rs
+++ b/arrow-array/src/array/byte_array.rs
@@ -282,13 +282,18 @@ impl<T: ByteArrayType> GenericByteArray<T> {
     /// Returns the values of this array
     ///
     /// Unlike [`Self::value_data`] this returns the [`Buffer`]
-    /// allowing for zero-copy cloning
+    /// allowing for zero-copy cloning. Like [`Self::value_data`], it can
+    /// include bytes that are not part of any value.
     #[inline]
     pub fn values(&self) -> &Buffer {
         &self.value_data
     }
 
     /// Returns the raw value data
+    ///
+    /// This can include bytes before the first offset or after the last one,
+    /// for example when the array is a slice of a larger array. Those bytes 
are
+    /// not part of any value, and in a string array they need not be valid 
UTF-8.
     pub fn value_data(&self) -> &[u8] {
         self.value_data.as_slice()
     }
diff --git a/arrow-array/src/array/string_array.rs 
b/arrow-array/src/array/string_array.rs
index c98b2a1b2a..39427181d2 100644
--- a/arrow-array/src/array/string_array.rs
+++ b/arrow-array/src/array/string_array.rs
@@ -504,6 +504,29 @@ mod tests {
         let _ = StringArray::from(list);
     }
 
+    #[test]
+    fn test_string_array_invalid_bytes_outside_offsets() {
+        // Only the bytes that the offsets span need to be valid UTF-8, as in a
+        // slice of a larger array
+        let values = Buffer::from_slice_ref(b"\xFFa\xC3\xA9\xFF");
+        let offsets = OffsetBuffer::new(vec![1, 2, 4].into());
+        let string = StringArray::try_new(offsets, values.clone(), 
None).unwrap();
+        assert_eq!(string, StringArray::from(vec!["a", "é"]));
+
+        let offsets = OffsetBuffer::new(vec![1, 3, 4].into());
+        let err = StringArray::try_new(offsets, values, None).unwrap_err();
+        assert_eq!(
+            err.to_string(),
+            "Invalid argument error: Split UTF-8 codepoint at offset 3"
+        );
+
+        // An empty value whose offset is inside a character, in a values 
buffer
+        // that is valid UTF-8 as a whole. ArrayData validation must agree.
+        let offsets = OffsetBuffer::new(vec![1, 1].into());
+        let string = StringArray::try_new(offsets, 
Buffer::from_slice_ref("é"), None).unwrap();
+        string.to_data().validate_full().unwrap();
+    }
+
     #[test]
     fn test_empty_offsets() {
         let string = StringArray::from(
diff --git a/arrow-array/src/types.rs b/arrow-array/src/types.rs
index ba0d88ae64..c7f2d51989 100644
--- a/arrow-array/src/types.rs
+++ b/arrow-array/src/types.rs
@@ -1686,23 +1686,26 @@ impl<O: OffsetSizeTrait> ByteArrayType for 
GenericStringType<O> {
     };
 
     fn validate(offsets: &OffsetBuffer<Self::Offset>, values: &Buffer) -> 
Result<(), ArrowError> {
-        // Verify that the slice as a whole is valid UTF-8
-        let validated = std::str::from_utf8(values).map_err(|e| {
+        let start = offsets.first().as_usize();
+        let end = offsets.last().as_usize();
+        if end > values.len() {
+            return Err(ArrowError::InvalidArgumentError(format!(
+                "Offset of {end} exceeds length of values {}",
+                values.len()
+            )));
+        }
+
+        // Verify that the bytes the offsets span are valid UTF-8
+        let validated = std::str::from_utf8(&values[start..end]).map_err(|e| {
             ArrowError::InvalidArgumentError(format!("Encountered non UTF-8 
data: {e}"))
         })?;
 
-        // Verify each offset is at a valid character boundary in this UTF-8 
array
+        // Verify each offset is at a valid character boundary
         for offset in offsets.iter() {
             let o = offset.as_usize();
-            if !validated.is_char_boundary(o) {
-                if o < validated.len() {
-                    return Err(ArrowError::InvalidArgumentError(format!(
-                        "Split UTF-8 codepoint at offset {o}"
-                    )));
-                }
+            if !validated.is_char_boundary(o - start) {
                 return Err(ArrowError::InvalidArgumentError(format!(
-                    "Offset of {o} exceeds length of values {}",
-                    validated.len()
+                    "Split UTF-8 codepoint at offset {o}"
                 )));
             }
         }
diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index cd858859ea..3d7fb88c79 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -12465,6 +12465,34 @@ mod tests {
         assert_eq!(a.value(1), "\x00 Foo");
     }
 
+    #[test]
+    fn test_cast_sliced_binary_with_hidden_invalid_utf8() {
+        // The invalid first value is outside the slice, so it must not fail 
the cast
+        let values: Vec<&[u8]> = vec![b"\xFF", b"a", b"b", b"c", b"d", b"e", 
b"f"];
+        let arrays: [ArrayRef; 2] = [
+            Arc::new(BinaryArray::from(values.clone()).slice(1, 6)),
+            Arc::new(LargeBinaryArray::from(values).slice(1, 6)),
+        ];
+        let options = CastOptions {
+            safe: false,
+            format_options: FormatOptions::default(),
+        };
+        let expected = StringArray::from(vec!["a", "b", "c", "d", "e", "f"]);
+        for array in &arrays {
+            for to_type in [DataType::Utf8, DataType::LargeUtf8, 
DataType::Utf8View] {
+                let result = cast_with_options(array, &to_type, 
&options).unwrap();
+                assert_eq!(result.as_ref(), cast(&expected, 
&to_type).unwrap().as_ref());
+            }
+
+            // A dictionary with fewer keys than half its values takes a 
separate
+            // path to Utf8View
+            let keys = Int32Array::from(vec![5, 0]);
+            let dict = DictionaryArray::try_new(keys, 
Arc::clone(array)).unwrap();
+            let result = cast_with_options(&dict, &DataType::Utf8View, 
&options).unwrap();
+            assert_eq!(result.as_ref(), &StringViewArray::from(vec!["f", 
"a"]));
+        }
+    }
+
     #[test]
     fn test_cast_utf8_to_timestamptz() {
         let valid = StringArray::from(vec!["2023-01-01"]);
diff --git a/arrow-cast/src/cast/string.rs b/arrow-cast/src/cast/string.rs
index 8d6255f151..c193eacc6e 100644
--- a/arrow-cast/src/cast/string.rs
+++ b/arrow-cast/src/cast/string.rs
@@ -369,9 +369,11 @@ pub(crate) fn cast_binary_to_string<O: OffsetSizeTrait>(
         Ok(a) => Ok(Arc::new(a)),
         Err(e) => match cast_options.safe {
             true => {
-                // Fallback to slow method to convert invalid sequences to 
nulls
-                let mut builder =
-                    GenericStringBuilder::<O>::with_capacity(array.len(), 
array.value_data().len());
+                // Fallback to slow method to convert invalid sequences to 
nulls.
+                // Reserve only the bytes the offsets span, not the whole 
values buffer.
+                let offsets = array.offsets();
+                let data_len = (offsets.last() - offsets.first()).as_usize();
+                let mut builder = 
GenericStringBuilder::<O>::with_capacity(array.len(), data_len);
 
                 extend_valid_utf8(&mut builder, array.iter());
                 Ok(Arc::new(builder.finish()))
diff --git a/arrow-data/src/data.rs b/arrow-data/src/data.rs
index 7404808a16..c2f32ef9d0 100644
--- a/arrow-data/src/data.rs
+++ b/arrow-data/src/data.rs
@@ -272,6 +272,19 @@ fn checked_len_plus_offset(
     })
 }
 
+/// Returns the first and last of `offsets`, and the bytes of `values` between
+/// them as a `str`. Returns `None` if the offsets are not valid indexes into
+/// `values` or those bytes are not valid UTF-8.
+fn utf8_span<'a, T: ArrowNativeType>(
+    offsets: &[T],
+    values: &'a [u8],
+) -> Option<(usize, usize, &'a str)> {
+    let first = offsets.first()?.to_usize()?;
+    let last = offsets.last()?.to_usize()?;
+    let values_str = std::str::from_utf8(values.get(first..last)?).ok()?;
+    Some((first, last, values_str))
+}
+
 impl ArrayData {
     /// Create a new ArrayData instance;
     ///
@@ -1696,18 +1709,24 @@ impl ArrayData {
     }
 
     /// Ensures that all strings formed by the offsets in `buffers[0]`
-    /// into `buffers[1]` are valid utf8 sequences
+    /// into `buffers[1]` are valid utf8 sequences. Bytes of `buffers[1]`
+    /// outside the range of the offsets are not checked.
     fn validate_utf8<T>(&self) -> Result<(), ArrowError>
     where
         T: ArrowNativeType + TryInto<usize> + num_traits::Num + 
std::fmt::Display,
     {
         let values_buffer = &self.buffer_at(1)?.as_slice();
-        if let Ok(values_str) = std::str::from_utf8(values_buffer) {
-            // Validate Offsets are correct
+        let offsets = self.typed_offsets::<T>()?;
+        if let Some((first, last, values_str)) = utf8_span(offsets, 
values_buffer) {
             self.validate_each_offset::<T, _>(values_buffer.len(), 
|string_index, range| {
-                if !values_str.is_char_boundary(range.start)
-                    || !values_str.is_char_boundary(range.end)
-                {
+                // `values_str` ends at the last offset. Checking each pair of 
offsets doesn't
+                // show that `range.end <= last`: a later offset can still be 
smaller, which
+                // `validate_each_offset` reports when it gets there.
+                if range.end > last {
+                    return Ok(());
+                }
+                let (start, end) = (range.start - first, range.end - first);
+                if !values_str.is_char_boundary(start) || 
!values_str.is_char_boundary(end) {
                     return Err(ArrowError::InvalidArgumentError(format!(
                         "incomplete utf-8 byte sequence from index 
{string_index}"
                     )));
@@ -1715,7 +1734,7 @@ impl ArrayData {
                 Ok(())
             })
         } else {
-            // find specific offset that failed utf8 validation
+            // Find specific offset that failed utf8 validation
             self.validate_each_offset::<T, _>(values_buffer.len(), 
|string_index, range| {
                 std::str::from_utf8(&values_buffer[range.clone()]).map_err(|e| 
{
                     ArrowError::InvalidArgumentError(format!(
diff --git a/arrow-string/src/substring.rs b/arrow-string/src/substring.rs
index 82171b5a1d..dfc10e099c 100644
--- a/arrow-string/src/substring.rs
+++ b/arrow-string/src/substring.rs
@@ -350,14 +350,17 @@ where
 
     // When array is [Large]StringArray, we will check whether `offset` is at 
a valid char boundary.
     let check_char_boundary = {
-        |offset: T::Offset| {
+        let first = array.offsets().first().as_usize();
+        let last = array.offsets().last().as_usize();
+        move |offset: T::Offset| {
             if !matches!(T::DATA_TYPE, DataType::Utf8 | DataType::LargeUtf8) {
                 return Ok(offset);
             }
-            // Safety: a StringArray must contain valid UTF8 data
-            let data_str = unsafe { std::str::from_utf8_unchecked(data) };
+            // Safety: in a StringArray, the bytes between the first and last 
offsets
+            // are valid UTF-8. The bytes outside that range need not be.
+            let data_str = unsafe { 
std::str::from_utf8_unchecked(&data[first..last]) };
             let offset_usize = offset.as_usize();
-            if data_str.is_char_boundary(offset_usize) {
+            if data_str.is_char_boundary(offset_usize - first) {
                 Ok(offset)
             } else {
                 Err(ArrowError::ComputeError(format!(
@@ -1129,6 +1132,15 @@ mod tests {
         assert!(err.contains("invalid utf-8 boundary"));
     }
 
+    #[test]
+    fn check_invalid_bytes_outside_offsets() {
+        // The values buffer holds invalid UTF-8 before and after the visible 
values
+        let values = Buffer::from_slice_ref(b"\xFFab\x80");
+        let array = StringArray::new(OffsetBuffer::new(vec![1, 3].into()), 
values, None);
+        let result = substring(&array, 0, Some(5)).unwrap();
+        assert_eq!(result.as_string::<i32>(), &StringArray::from(vec!["ab"]));
+    }
+
     #[test]
     fn non_utf8_bytes() {
         // non-utf8 bytes

Reply via email to