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 aa60e22d60 fix(arrow-string): saturate the offset casts in substring 
(#10995)
aa60e22d60 is described below

commit aa60e22d60bb9fa37d5bc550db11fc6e61033fa7
Author: pawan <[email protected]>
AuthorDate: Mon Sep 21 14:41:43 2026 +0530

    fix(arrow-string): saturate the offset casts in substring (#10995)
    
    # Which issue does this PR close?
    
    - Closes #10983.
    
    # Rationale for this change
    
    substring takes start as i64 and length as Option<u64> and cast both
    straight into
    the offset type. on the Utf8 and Binary arms a start at or above 2^31
    wrapped
    negative, and `byte_substring` reads a negative start as counting from
    the end of
    the value, so the call did not fail, it quietly did something else.
    LargeUtf8 and
    LargeBinary narrow to i64 and were not affected, so the same call
    returned
    different data depending only on the offset width of the input.
    
    saturating rather than rejecting, per the discussion on the issue. a
    start past the
    end of every value is what the caller asked for, and it is already what
    the 64 bit
    arms do.
    
    # What changes are included in this PR?
    
    start and length saturate into the offset type at the dispatch instead
    of being
    cast. inside `byte_substring` the two additions that can carry a
    saturated value
    past the offset type go through `checked_add` and clamp to the end of
    the value.
    
    one thing i did not expect. the length cast was wrong on all four arms,
    not only
    the 32 bit ones. `u64::MAX as i64` is -1, and a negative length puts the
    end of a
    substring before its start, which drives the output offsets negative and
    then
    allocates on the result of as_usize. i only found it because the test
    compares the
    narrow and wide arms against each other and the wide one panicked. so
    LargeUtf8 and
    LargeBinary are fixed here too.
    
    the third addition, pair[1] + start on the negative branch, is left
    alone. pair[1]
    is non-negative and start is at worst i32::MIN, so it cannot overflow.
    
    # Are these changes tested?
    
    yes. out_of_range_start_and_length_match_the_64_bit_arms runs four out
    of range
    starts against three lengths, on Utf8 against LargeUtf8 and on Binary
    against
    LargeBinary, and asserts the pairs agree. it also pins the answer
    itself, since
    agreeing on the wrong result would still pass. skipping 2^31 characters
    of a five
    character string gives empty strings, and a start that already fits is
    untouched.
    
    on current main that test fails twice. Utf8 returns ["hello", "world"]
    where
    LargeUtf8 returns ["", ""], and Some(u64::MAX) panics inside
    `MutableBuffer`.
    
    arrow-string is 189 passed, and fmt and clippy with -D warnings are
    clean.
    
    # Are there any user-facing changes?
    
    yes, for input that was previously wrong. a start or length outside the
    offset type
    now saturates, so Utf8 and Binary return what LargeUtf8 and LargeBinary
    already
    returned. values that fit are unaffected.
---
 arrow-string/src/substring.rs | 106 ++++++++++++++++++++++++++++++++++++------
 1 file changed, 93 insertions(+), 13 deletions(-)

diff --git a/arrow-string/src/substring.rs b/arrow-string/src/substring.rs
index 6422f7b167..82171b5a1d 100644
--- a/arrow-string/src/substring.rs
+++ b/arrow-string/src/substring.rs
@@ -26,7 +26,7 @@ use arrow_array::types::*;
 use arrow_array::*;
 use arrow_buffer::{ArrowNativeType, MutableBuffer, NullBuffer, OffsetBuffer};
 use arrow_schema::{ArrowError, DataType};
-use num_traits::Zero;
+use num_traits::{CheckedAdd, Zero};
 use std::cmp::Ordering;
 use std::sync::Arc;
 
@@ -81,13 +81,17 @@ pub fn substring(
             let values = substring(dictionary.values(), start, length)?;
             Ok(Arc::new(dictionary.with_values(values)))
         }
-        DataType::LargeBinary => {
-            byte_substring(array.as_binary::<i64>(), start, length.map(|e| e 
as i64))
-        }
+        DataType::LargeBinary => byte_substring(
+            array.as_binary::<i64>(),
+            start,
+            // ensure we saturate to not wrap around to a negative length
+            length.map(u64_to_i64_saturating),
+        ),
         DataType::Binary => byte_substring(
             array.as_binary::<i32>(),
-            start as i32,
-            length.map(|e| e as i32),
+            // ensure to saturate to avoid wrapping to negative which is a 
different behaviour
+            i64_to_i32_saturating(start),
+            length.map(u64_to_i32_saturating),
         ),
         DataType::FixedSizeBinary(old_len) => {
             let old_len: usize = (*old_len)
@@ -95,13 +99,17 @@ pub fn substring(
                 .expect("negative FixedSizeBinary value length");
             fixed_size_binary_substring(array.as_fixed_size_binary(), old_len, 
start, length)
         }
-        DataType::LargeUtf8 => {
-            byte_substring(array.as_string::<i64>(), start, length.map(|e| e 
as i64))
-        }
+        DataType::LargeUtf8 => byte_substring(
+            array.as_string::<i64>(),
+            start,
+            // ensure we saturate to not wrap around to a negative length
+            length.map(u64_to_i64_saturating),
+        ),
         DataType::Utf8 => byte_substring(
             array.as_string::<i32>(),
-            start as i32,
-            length.map(|e| e as i32),
+            // ensure to saturate to avoid wrapping to negative which is a 
different behaviour
+            i64_to_i32_saturating(start),
+            length.map(u64_to_i32_saturating),
         ),
         DataType::Utf8View => string_view_substring(array.as_string_view(), 
start, length),
         DataType::BinaryView => binary_view_substring(array.as_binary_view(), 
start, length),
@@ -316,6 +324,18 @@ fn binary_view_substring(
     Ok(Arc::new(builder.finish()))
 }
 
+fn i64_to_i32_saturating(value: i64) -> i32 {
+    value.clamp(i32::MIN as i64, i32::MAX as i64) as i32
+}
+
+fn u64_to_i32_saturating(value: u64) -> i32 {
+    value.min(i32::MAX as u64) as i32
+}
+
+fn u64_to_i64_saturating(value: u64) -> i64 {
+    value.min(i64::MAX as u64) as i64
+}
+
 fn byte_substring<T: ByteArrayType>(
     array: &GenericByteArray<T>,
     start: T::Offset,
@@ -357,12 +377,21 @@ where
         .windows(2)
         .try_for_each(|pair| -> Result<(), ArrowError> {
             let new_start = match start.cmp(&zero) {
-                Ordering::Greater => check_char_boundary((pair[0] + 
start).min(pair[1]))?,
+                Ordering::Greater => {
+                    // a saturated start can carry pair[0] + start past the 
offset
+                    // type. that means past the end of this value, so clamp 
to the
+                    // end rather than let the add wrap.
+                    let shifted = 
pair[0].checked_add(&start).unwrap_or(pair[1]);
+                    check_char_boundary(shifted.min(pair[1]))?
+                }
                 Ordering::Equal => pair[0],
                 Ordering::Less => check_char_boundary((pair[1] + 
start).max(pair[0]))?,
             };
             let new_end = match length {
-                Some(length) => check_char_boundary((length + 
new_start).min(pair[1]))?,
+                Some(length) => {
+                    let end = 
length.checked_add(&new_start).unwrap_or(pair[1]);
+                    check_char_boundary(end.min(pair[1]))?
+                }
                 None => pair[1],
             };
             len_so_far += new_end - new_start;
@@ -1236,4 +1265,55 @@ mod tests {
             vec![Some("hel"), Some("bye")]
         );
     }
+
+    #[test]
+    fn out_of_range_start_and_length_match_the_64_bit_arms() {
+        // use 64 bit offset versions as expected behaviour for extreme start 
& length values
+        // which should saturate and not wrap
+        let values = vec![Some("hello"), Some("world"), None];
+        let utf8 = StringArray::from(values.clone());
+        let large = LargeStringArray::from(values.clone());
+        let binary = BinaryArray::from_iter(values.iter().map(|v| v.map(|s| 
s.as_bytes())));
+        let large_binary =
+            LargeBinaryArray::from_iter(values.iter().map(|v| v.map(|s| 
s.as_bytes())));
+
+        // starts and lengths that do not survive a cast to i32
+        let starts: [i64; 4] = [1 << 31, (1 << 31) + 5, 1 << 32, i64::MAX];
+        let lengths: [Option<u64>; 3] = [None, Some(1 << 31), Some(u64::MAX)];
+
+        for start in starts {
+            for length in lengths {
+                let narrow = substring(&utf8, start, length).unwrap();
+                let wide = substring(&large, start, length).unwrap();
+                assert_eq!(
+                    narrow.as_string::<i32>().iter().collect::<Vec<_>>(),
+                    wide.as_string::<i64>().iter().collect::<Vec<_>>(),
+                    "Utf8 and LargeUtf8 disagree at start {start}, length 
{length:?}"
+                );
+
+                let narrow = substring(&binary, start, length).unwrap();
+                let wide = substring(&large_binary, start, length).unwrap();
+                assert_eq!(
+                    narrow.as_binary::<i32>().iter().collect::<Vec<_>>(),
+                    wide.as_binary::<i64>().iter().collect::<Vec<_>>(),
+                    "Binary and LargeBinary disagree at start {start}, length 
{length:?}"
+                );
+            }
+        }
+
+        // and the answer itself is the sensible one: skipping more characters
+        // than the value holds leaves nothing behind.
+        let out = substring(&utf8, 1 << 31, None).unwrap();
+        assert_eq!(
+            out.as_string::<i32>().iter().collect::<Vec<_>>(),
+            vec![Some(""), Some(""), None]
+        );
+
+        // starts that already fit are untouched
+        let out = substring(&utf8, 3, None).unwrap();
+        assert_eq!(
+            out.as_string::<i32>().iter().collect::<Vec<_>>(),
+            vec![Some("lo"), Some("ld"), None]
+        );
+    }
 }

Reply via email to