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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3dddf121 feat(table): decode the BucketVectorSearchSplit byte form 
(#746)
3dddf121 is described below

commit 3dddf1218756d059ac8075b20e5d13a15d5a0fcf
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Aug 27 15:45:57 2026 +0800

    feat(table): decode the BucketVectorSearchSplit byte form (#746)
---
 crates/paimon/src/spec/binary_row.rs               | 248 ++++++
 .../goldens/bucket_vector_search_split_v1.bin      | Bin 0 -> 919 bytes
 ...ket_vector_search_split_v1_deletion_vectors.bin | Bin 0 -> 1235 bytes
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/pk_vector_bucket_split.rs  | 927 +++++++++++++++++++++
 crates/paimon/src/table/source.rs                  |  12 +-
 6 files changed, 1183 insertions(+), 6 deletions(-)

diff --git a/crates/paimon/src/spec/binary_row.rs 
b/crates/paimon/src/spec/binary_row.rs
index 15a3048c..7f430a39 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -804,6 +804,97 @@ pub fn deserialize_binary_array_str(data: &[u8]) -> 
crate::Result<Vec<String>> {
     Ok(out)
 }
 
+/// Read a `BinaryArray` of non-null `int` (Java `array<int>`): 4-byte element
+/// slots after the header, so the layout differs from the 8-byte slots the
+/// `bigint` and pointer forms use.
+pub(crate) fn deserialize_binary_array_int(data: &[u8]) -> 
crate::Result<Vec<i32>> {
+    let n = read_binary_array_len(data)?;
+    let header = binary_array_header(n);
+    // Bound the count against the buffer before multiplying it, so a forged 
one
+    // cannot wrap the offset arithmetic below -- the reason
+    // `check_binary_array_fits` is written the way it is, which this cannot 
use
+    // because it assumes the 8-byte slot width.
+    if n > data.len().saturating_sub(header) / 4 {
+        return Err(bin_arr_err(
+            "int array element region exceeds buffer length",
+        ));
+    }
+    // An int array has no variable-length part, so the writer's own size is 
the
+    // fixed region rounded up to a word -- exactly, not at least. Requiring 
the
+    // equality rejects both an unpadded region and trailing bytes, neither of
+    // which the writer can emit.
+    if round_to_word(header + n * 4) != data.len() {
+        return Err(bin_arr_err(
+            "int array size is not its word-padded fixed region",
+        ));
+    }
+    let mut out = Vec::with_capacity(n);
+    for k in 0..n {
+        if data.get(4 + k / 8).is_some_and(|b| b & (1 << (k % 8)) != 0) {
+            return Err(bin_arr_err("int element must not be null"));
+        }
+        let eo = header + k * 4;
+        let slot = data
+            .get(eo..eo + 4)
+            .ok_or_else(|| bin_arr_err("int element slot out of range"))?;
+        out.push(i32::from_le_bytes(slot.try_into().unwrap()));
+    }
+    Ok(out)
+}
+
+/// Read a `BinaryArray` whose elements are rows, returning each element's raw
+/// bytes. Rows are addressed the way variable-length fields are, by a packed
+/// offset and length, so the caller decodes each slice with the arity its own
+/// schema fixes.
+pub(crate) fn deserialize_binary_array_rows(data: &[u8]) -> 
crate::Result<Vec<&[u8]>> {
+    let n = read_binary_array_len(data)?;
+    let header = binary_array_header(n);
+    // Bounds the count before `n * 8` is computed, so a forged one cannot 
wrap it.
+    check_binary_array_fits(n, header, data.len())?;
+    let fixed_part = round_to_word(header + n * 8);
+    if fixed_part > data.len() {
+        return Err(bin_arr_err(
+            "row array element region exceeds buffer length",
+        ));
+    }
+    // The writer appends each row after the last, word-padded, so an element 
body
+    // starts exactly where the previous one ended and the last ends at the 
array's
+    // own end. Requiring that leaves no layout the writer cannot emit: no 
gap, no
+    // trailing bytes, and no two elements sharing a body -- which would let a
+    // small array drive a decode many times its own size.
+    let mut next = fixed_part;
+    let mut out = Vec::with_capacity(n);
+    for k in 0..n {
+        if data.get(4 + k / 8).is_some_and(|b| b & (1 << (k % 8)) != 0) {
+            return Err(bin_arr_err("row element must not be null"));
+        }
+        let eo = header + k * 8;
+        let slot = data
+            .get(eo..eo + 8)
+            .ok_or_else(|| bin_arr_err("row element slot out of range"))?;
+        let encoded = u64::from_le_bytes(slot.try_into().unwrap());
+        let offset = (encoded >> 32) as usize;
+        let length = (encoded & 0xFFFF_FFFF) as usize;
+        if offset != next {
+            return Err(bin_arr_err(
+                "row element body must start where the previous element ended",
+            ));
+        }
+        let end = offset
+            .checked_add(length)
+            .ok_or_else(|| bin_arr_err("row element bytes out of range"))?;
+        out.push(
+            data.get(offset..end)
+                .ok_or_else(|| bin_arr_err("row element bytes out of range"))?,
+        );
+        next = round_to_word(end);
+    }
+    if next != data.len() {
+        return Err(bin_arr_err("row array has bytes after its last element"));
+    }
+    Ok(out)
+}
+
 /// Reverse of [`serialize_binary_array_long`].
 pub fn deserialize_binary_array_long(data: &[u8]) -> 
crate::Result<Vec<Option<i64>>> {
     let n = read_binary_array_len(data)?;
@@ -1576,6 +1667,163 @@ mod tests {
     use super::*;
     use crate::variant::GenericVariant;
 
+    /// Java writes `array<int>` with 4-byte element slots, so the layout 
differs
+    /// from the 8-byte forms; build one by hand and read it back.
+    #[test]
+    fn deserialize_binary_array_int_reads_four_byte_slots() {
+        let values: [i32; 3] = [3, 5, -9];
+        // count + one null-bitset word. The writer rounds the fixed region up 
to
+        // a word, so three 4-byte slots occupy 16 bytes, not 12.
+        let header = 4 + 4;
+        let mut data = vec![0u8; round_to_word(header + values.len() * 4)];
+        data[0..4].copy_from_slice(&(values.len() as i32).to_le_bytes());
+        for (k, v) in values.iter().enumerate() {
+            let offset = header + k * 4;
+            data[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
+        }
+        assert_eq!(
+            deserialize_binary_array_int(&data).unwrap(),
+            values.to_vec()
+        );
+    }
+
+    /// A fixed region the writer would have padded is not a layout it can 
emit.
+    #[test]
+    fn deserialize_binary_array_int_rejects_an_unpadded_fixed_region() {
+        let header = 4 + 4;
+        let mut data = vec![0u8; header + 3 * 4];
+        data[0..4].copy_from_slice(&3i32.to_le_bytes());
+        assert!(deserialize_binary_array_int(&data).is_err());
+    }
+
+    #[test]
+    fn deserialize_binary_array_int_rejects_a_null_element() {
+        let header = 4 + 4;
+        let mut data = vec![0u8; round_to_word(header + 4)];
+        data[0..4].copy_from_slice(&1i32.to_le_bytes());
+        data[4] = 1; // element 0 is null
+        let error = deserialize_binary_array_int(&data).unwrap_err();
+        assert!(
+            error.to_string().contains("int element must not be null"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn deserialize_binary_array_int_rejects_a_count_past_the_buffer() {
+        let mut data = vec![0u8; 8];
+        data[0..4].copy_from_slice(&i32::MAX.to_le_bytes());
+        assert!(deserialize_binary_array_int(&data).is_err());
+    }
+
+    /// Row elements are addressed like variable-length fields, by a packed 
offset
+    /// and length, and the writer appends each body after the last, 
word-padded.
+    fn binary_array_of_rows(bodies: &[&[u8]], alias_all_at: Option<usize>) -> 
Vec<u8> {
+        let header = 4 + 4;
+        let mut data = vec![0u8; round_to_word(header + bodies.len() * 8)];
+        data[0..4].copy_from_slice(&(bodies.len() as i32).to_le_bytes());
+        let mut offsets = Vec::new();
+        for body in bodies {
+            offsets.push(data.len());
+            data.extend_from_slice(body);
+            let pad = (8 - (body.len() % 8)) % 8;
+            data.extend(std::iter::repeat_n(0u8, pad));
+        }
+        for (k, body) in bodies.iter().enumerate() {
+            let (offset, length) = match alias_all_at {
+                Some(shared) => (shared, bodies[0].len()),
+                None => (offsets[k], body.len()),
+            };
+            let encoded = ((offset as u64) << 32) | (length as u64);
+            let slot = header + k * 8;
+            data[slot..slot + 8].copy_from_slice(&encoded.to_le_bytes());
+        }
+        data
+    }
+
+    #[test]
+    fn deserialize_binary_array_rows_returns_element_slices() {
+        let data = binary_array_of_rows(&[&[0xAA; 3], &[0xBB; 5]], None);
+        let elements = deserialize_binary_array_rows(&data).unwrap();
+        assert_eq!(
+            elements,
+            vec![[0xAAu8; 3].as_slice(), [0xBBu8; 5].as_slice()]
+        );
+    }
+
+    /// The writer never points two elements at one body. Accepting it would 
let a
+    /// small array drive a decode many times its own size.
+    #[test]
+    fn deserialize_binary_array_rows_rejects_aliased_elements() {
+        let bodies: [&[u8]; 3] = [&[0xAA; 8], &[0xBB; 8], &[0xCC; 8]];
+        let first_body = round_to_word(4 + 4 + bodies.len() * 8);
+        let data = binary_array_of_rows(&bodies, Some(first_body));
+        let error = deserialize_binary_array_rows(&data).unwrap_err();
+        assert!(
+            error.to_string().contains("must start where the previous"),
+            "unexpected error: {error}"
+        );
+    }
+
+    /// The writer leaves no gap between bodies either, so a body one word 
late is
+    /// as much a forgery as one that overlaps.
+    #[test]
+    fn deserialize_binary_array_rows_rejects_a_gap_before_an_element() {
+        let header = 4 + 4;
+        let fixed_part = round_to_word(header + 8);
+        // One word of gap, then a body that is itself inside the buffer, so 
this
+        // can only fail on the gap.
+        let mut data = vec![0u8; fixed_part + 8 + 8];
+        data[0..4].copy_from_slice(&1i32.to_le_bytes());
+        let encoded = (((fixed_part + 8) as u64) << 32) | 8;
+        data[header..header + 8].copy_from_slice(&encoded.to_le_bytes());
+        let error = deserialize_binary_array_rows(&data).unwrap_err();
+        assert!(
+            error.to_string().contains("must start where the previous"),
+            "unexpected error: {error}"
+        );
+    }
+
+    /// The array ends where its last element ends, so trailing bytes are a 
layout
+    /// the writer cannot emit.
+    #[test]
+    fn deserialize_binary_array_rows_rejects_trailing_bytes() {
+        let mut data = binary_array_of_rows(&[&[0xAA; 8]], None);
+        data.push(0);
+        let error = deserialize_binary_array_rows(&data).unwrap_err();
+        assert!(
+            error.to_string().contains("after its last element"),
+            "unexpected error: {error}"
+        );
+    }
+
+    /// Counterpart to the int reader's test: a forged count must be rejected
+    /// before it reserves anything.
+    #[test]
+    fn deserialize_binary_array_rows_rejects_a_count_past_the_buffer() {
+        let mut data = vec![0u8; 8];
+        data[0..4].copy_from_slice(&i32::MAX.to_le_bytes());
+        assert!(deserialize_binary_array_rows(&data).is_err());
+    }
+
+    /// An element body inside the fixed part is likewise not a layout the 
writer
+    /// can emit.
+    #[test]
+    fn deserialize_binary_array_rows_rejects_a_body_in_the_fixed_part() {
+        let data = binary_array_of_rows(&[&[0xAA; 8]], Some(0));
+        assert!(deserialize_binary_array_rows(&data).is_err());
+    }
+
+    #[test]
+    fn deserialize_binary_array_rows_rejects_an_element_past_the_buffer() {
+        let header = 4 + 4;
+        let mut data = vec![0u8; header + 8];
+        data[0..4].copy_from_slice(&1i32.to_le_bytes());
+        let encoded = ((data.len() as u64) << 32) | 16;
+        data[header..header + 8].copy_from_slice(&encoded.to_le_bytes());
+        assert!(deserialize_binary_array_rows(&data).is_err());
+    }
+
     #[test]
     fn test_empty_binary_row() {
         let row = BinaryRow::new(0);
diff --git a/crates/paimon/src/table/goldens/bucket_vector_search_split_v1.bin 
b/crates/paimon/src/table/goldens/bucket_vector_search_split_v1.bin
new file mode 100644
index 00000000..12b9d100
Binary files /dev/null and 
b/crates/paimon/src/table/goldens/bucket_vector_search_split_v1.bin differ
diff --git 
a/crates/paimon/src/table/goldens/bucket_vector_search_split_v1_deletion_vectors.bin
 
b/crates/paimon/src/table/goldens/bucket_vector_search_split_v1_deletion_vectors.bin
new file mode 100644
index 00000000..cad97fb9
Binary files /dev/null and 
b/crates/paimon/src/table/goldens/bucket_vector_search_split_v1_deletion_vectors.bin
 differ
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index b415f0dd..f8b99f9f 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -68,6 +68,7 @@ mod pk_full_text_read;
 mod pk_full_text_scan;
 mod pk_search_position;
 mod pk_search_ranker;
+mod pk_vector_bucket_split;
 mod pk_vector_data_file_reader;
 mod pk_vector_indexed_split_read;
 mod pk_vector_orchestrator;
@@ -126,6 +127,7 @@ pub use incremental_scan::{
 };
 pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
 pub use partition_stat::PartitionStat;
+pub use pk_vector_bucket_split::{BucketVectorPayload, BucketVectorSearchSplit};
 pub use postpone_bucket_plan::{PostponeBucketPlan, 
POSTPONE_BUCKET_PLAN_TOTAL_BUCKETS_FIELD};
 pub use postpone_fixed_bucket_write::{
     PostponeFixedBucketTableCommit, PostponeFixedBucketTableWrite,
diff --git a/crates/paimon/src/table/pk_vector_bucket_split.rs 
b/crates/paimon/src/table/pk_vector_bucket_split.rs
new file mode 100644
index 00000000..0c50b675
--- /dev/null
+++ b/crates/paimon/src/table/pk_vector_bucket_split.rs
@@ -0,0 +1,927 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Reader for the byte form of Java's `BucketVectorSearchSplit`.
+//!
+//! A primary-key vector search is planned per bucket, because deciding which 
ANN
+//! segments are current needs the bucket's complete active-file set rather 
than
+//! the arbitrary file subset an ordinary table-scan split carries. Java plans
+//! those splits and serializes them in this form; this module decodes them so
+//! the search can then run outside the JVM.
+
+use std::collections::HashMap;
+
+use indexmap::IndexMap;
+
+use crate::spec::{
+    deserialize_binary_array_int, deserialize_binary_array_rows, BinaryRow, 
DeletionVectorMeta,
+    GlobalIndexMeta,
+};
+use crate::table::source::{read_i32, read_i64, read_java_utf};
+use crate::table::{DataSplit, RowRange};
+
+/// `"PKVSPLIT"` in ASCII.
+const MAGIC: i64 = 0x504B_5653_504C_4954;
+const VERSION: i32 = 1;
+
+/// Field counts of the rows nested in a payload. They come from the writer's
+/// schema rather than the bytes, so both sides have to agree on them; a change
+/// to either schema is what `VERSION` exists to signal.
+const PAYLOAD_ARITY: i32 = 7;
+const GLOBAL_INDEX_ARITY: i32 = 6;
+const DELETION_VECTOR_ARITY: i32 = 4;
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+/// All active data files and primary-key vector index payloads for one 
snapshot
+/// bucket.
+///
+/// Reference: 
[org.apache.paimon.table.source.BucketVectorSearchSplit](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/BucketVectorSearchSplit.java)
+#[derive(Debug, Clone, PartialEq)]
+pub struct BucketVectorSearchSplit {
+    data_split: DataSplit,
+    payload_files: Vec<BucketVectorPayload>,
+    /// Rows to keep per data file, as inclusive positions local to that file.
+    /// Kept in the order the message carries them -- the writer sorts by file
+    /// name -- so iterating a decoded split is reproducible; lookups are still
+    /// by name.
+    row_ranges_by_file: IndexMap<String, Vec<RowRange>>,
+}
+
+/// One primary-key vector index file carried by a [`BucketVectorSearchSplit`].
+///
+/// A distinct type from [`crate::spec::IndexFileMeta`], which models the
+/// manifest form: that one has no external path and narrows the row count to
+/// `i32`, while the schema behind these bytes has both.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BucketVectorPayload {
+    index_type: String,
+    file_name: String,
+    file_size: i64,
+    row_count: i64,
+    deletion_vectors_ranges: Option<IndexMap<String, DeletionVectorMeta>>,
+    external_path: Option<String>,
+    global_index_meta: GlobalIndexMeta,
+}
+
+impl BucketVectorPayload {
+    pub fn index_type(&self) -> &str {
+        &self.index_type
+    }
+
+    pub fn file_name(&self) -> &str {
+        &self.file_name
+    }
+
+    pub fn file_size(&self) -> i64 {
+        self.file_size
+    }
+
+    pub fn row_count(&self) -> i64 {
+        self.row_count
+    }
+
+    /// Deletion vector ranges. Always absent on a vector payload, but the 
schema
+    /// can express them, so they are carried rather than dropped.
+    pub fn deletion_vectors_ranges(&self) -> Option<&IndexMap<String, 
DeletionVectorMeta>> {
+        self.deletion_vectors_ranges.as_ref()
+    }
+
+    pub fn external_path(&self) -> Option<&str> {
+        self.external_path.as_deref()
+    }
+
+    /// Global index metadata. The schema makes the row nullable, but a
+    /// `BucketVectorSearchSplit` cannot hold a payload without one, so a 
message
+    /// that omits it is rejected rather than decoded into an absence callers
+    /// would have to handle.
+    pub fn global_index_meta(&self) -> &GlobalIndexMeta {
+        &self.global_index_meta
+    }
+
+    /// The `_SOURCE_META` blob, which maps ANN ordinals back to rows. Always
+    /// present, for the same reason.
+    pub fn source_meta(&self) -> &[u8] {
+        self.global_index_meta
+            .source_meta
+            .as_deref()
+            .expect("a decoded payload always carries source metadata")
+    }
+}
+
+impl BucketVectorSearchSplit {
+    /// The bucket's data files. Its own `row_ranges` are always absent here --
+    /// this form carries them per file in [`Self::row_ranges_by_file`] -- so
+    /// re-serializing it on its own would drop them.
+    pub fn data_split(&self) -> &DataSplit {
+        &self.data_split
+    }
+
+    pub fn payload_files(&self) -> &[BucketVectorPayload] {
+        &self.payload_files
+    }
+
+    pub fn row_ranges_by_file(&self) -> &IndexMap<String, Vec<RowRange>> {
+        &self.row_ranges_by_file
+    }
+
+    /// Parse a Java `BucketVectorSearchSplit#serialize` message.
+    ///
+    /// Integers are big-endian and file names are Java modified UTF-8, 
following
+    /// the split formats this one nests. The layout is:
+    ///
+    /// ```text
+    /// i64 magic = "PKVSPLIT"; i32 version = 1
+    /// DataSplit                                  // DataSplit.serialize, 
inline
+    /// i32 payloadCount; (i32 rowLength, IndexFileMeta row)*
+    /// i32 rangeFileCount; (utf fileName, i32 rangeCount, (i64 from, i64 
to)*)*
+    /// ```
+    ///
+    /// Each payload is one `BinaryRow` of Java's `IndexFileMeta.SCHEMA`, which
+    /// carries no version of its own. `VERSION` therefore pins the layout of
+    /// what is nested as well as the envelope, and has to move when either
+    /// schema does -- unlike the `DataSplit`, which carries its own version 
and
+    /// so may change underneath a message still labelled version 1.
+    ///
+    /// Consumes the entire buffer; trailing bytes are an error.
+    pub fn deserialize(bytes: &[u8]) -> crate::Result<Self> {
+        let mut input = bytes;
+        let cur = &mut input;
+
+        let magic = read_i64(cur)?;
+        if magic != MAGIC {
+            return Err(data_invalid(format!(
+                "invalid BucketVectorSearchSplit magic: {magic:#018x}"
+            )));
+        }
+        let version = read_i32(cur)?;
+        if version != VERSION {
+            return Err(crate::Error::Unsupported {
+                message: format!("BucketVectorSearchSplit version {version} is 
not supported"),
+            });
+        }
+
+        let data_split = DataSplit::read_body(cur).map_err(nested_error)?;
+
+        let payload_count = read_count(cur, "payload file")?;
+        let mut payload_files = Vec::new();
+        for _ in 0..payload_count {
+            payload_files.push(read_payload(cur)?);
+        }
+
+        // Row ranges are only meaningful against the data file they name, so 
index
+        // the bucket's files before reading them. Two files sharing a name 
would
+        // leave no way to say which of them a range belongs to; a bucket 
cannot
+        // hold such a pair and the planner rejects it, but these bytes are
+        // untrusted.
+        let mut row_counts: HashMap<&str, i64> = HashMap::new();
+        for file in data_split.data_files() {
+            if row_counts
+                .insert(file.file_name.as_str(), file.row_count)
+                .is_some()
+            {
+                return Err(data_invalid(format!(
+                    "duplicate data file in the bucket split: {}",
+                    file.file_name
+                )));
+            }
+        }
+
+        let range_file_count = read_count(cur, "row-range file")?;
+        let mut row_ranges_by_file = IndexMap::new();
+        for _ in 0..range_file_count {
+            let file_name = read_java_utf(cur)?;
+            let ranges = read_row_ranges(cur, &file_name, &row_counts)?;
+            if row_ranges_by_file
+                .insert(file_name.clone(), ranges)
+                .is_some()
+            {
+                return Err(data_invalid(format!(
+                    "duplicate row-range file entry: {file_name}"
+                )));
+            }
+        }
+
+        if !cur.is_empty() {
+            return Err(data_invalid(format!(
+                "{} trailing bytes after BucketVectorSearchSplit",
+                cur.len()
+            )));
+        }
+
+        Ok(Self {
+            data_split,
+            payload_files,
+            row_ranges_by_file,
+        })
+    }
+}
+
+/// Report a malformed nested structure as invalid data. The decoders reached
+/// from here signal a short buffer as an unexpected error, which reads as an
+/// internal fault; these bytes are untrusted, so the caller has to be able to
+/// tell a bad message from a bug. A version that cannot be read stays
+/// unsupported.
+fn nested_error(error: crate::Error) -> crate::Error {
+    match error {
+        crate::Error::Unsupported { .. } | crate::Error::DataInvalid { .. } => 
error,
+        other => crate::Error::DataInvalid {
+            message: "invalid nested structure in 
BucketVectorSearchSplit".to_string(),
+            source: Some(Box::new(other)),
+        },
+    }
+}
+
+/// Read the rows to keep in one data file, as inclusive positions local to 
that
+/// file. Java writes ranges its planner produced and re-checks nothing, so the
+/// checks here are what a reader of untrusted bytes needs rather than a mirror
+/// of the writer: `RowRange` cannot represent a descending pair at all, and a
+/// range outside its file would read rows that are not there. The file's own 
row
+/// count is checked first, so a forged one cannot lift that bound.
+fn read_row_ranges(
+    cur: &mut &[u8],
+    file_name: &str,
+    row_counts: &HashMap<&str, i64>,
+) -> crate::Result<Vec<RowRange>> {
+    let row_count = *row_counts.get(file_name).ok_or_else(|| {
+        data_invalid(format!(
+            "row ranges reference data file not present in the bucket split: 
{file_name}"
+        ))
+    })?;
+    // A negative row count is forged by construction. Rejecting it, rather 
than
+    // skipping the bound check for it, is what keeps the bound below 
meaningful:
+    // otherwise a forged count would lift it entirely.
+    if row_count < 0 {
+        return Err(data_invalid(format!(
+            "data file {file_name} has a negative row count: {row_count}"
+        )));
+    }
+
+    let count = read_count(cur, "row range")?;
+    let mut ranges: Vec<RowRange> = Vec::new();
+    for _ in 0..count {
+        let from = read_i64(cur)?;
+        let to = read_i64(cur)?;
+        if from > to {
+            return Err(data_invalid(format!(
+                "invalid row range [{from}, {to}] for file {file_name}"
+            )));
+        }
+        if from < 0 {
+            return Err(data_invalid(format!(
+                "negative row range [{from}, {to}] for file {file_name}"
+            )));
+        }
+        if to >= row_count {
+            return Err(data_invalid(format!(
+                "row range [{from}, {to}] for file {file_name} is outside [0, 
{row_count})"
+            )));
+        }
+        ranges.push(RowRange::new(from, to));
+    }
+    Ok(ranges)
+}
+
+/// Read one payload: an `i32` byte length followed by a `BinaryRow` of Java's
+/// `IndexFileMeta.SCHEMA`, the framing `ObjectSerializer` writes a record 
with.
+fn read_payload(cur: &mut &[u8]) -> crate::Result<BucketVectorPayload> {
+    let row = read_nested_row(cur, PAYLOAD_ARITY, "payload")?;
+    // Fields the schema declares NOT NULL. A null slot is zeroed rather than
+    // absent, so without this an empty name or a zero size would come back as 
a
+    // value the writer never wrote.
+    require_present(&row, &[0, 1, 2, 3], "payload")?;
+
+    let deletion_vectors_ranges = if row.is_null_at(4) {
+        None
+    } else {
+        Some(read_deletion_vector_ranges(
+            row.get_binary(4).map_err(nested_error)?,
+        )?)
+    };
+    let file_name = row.get_string(1).map_err(nested_error)?.to_string();
+    if row.is_null_at(6) {
+        return Err(data_invalid(format!(
+            "PK-vector payload {file_name} has no global index metadata"
+        )));
+    }
+    let global_index_meta = 
read_global_index_meta(row.get_binary(6).map_err(nested_error)?)?;
+    if global_index_meta.source_meta.is_none() {
+        return Err(data_invalid(format!(
+            "PK-vector payload {file_name} has no source metadata"
+        )));
+    }
+
+    Ok(BucketVectorPayload {
+        index_type: row.get_string(0).map_err(nested_error)?.to_string(),
+        file_name,
+        file_size: row.get_long(2).map_err(nested_error)?,
+        row_count: row.get_long(3).map_err(nested_error)?,
+        deletion_vectors_ranges,
+        external_path: if row.is_null_at(5) {
+            None
+        } else {
+            Some(row.get_string(5).map_err(nested_error)?.to_string())
+        },
+        global_index_meta,
+    })
+}
+
+fn read_global_index_meta(data: &[u8]) -> crate::Result<GlobalIndexMeta> {
+    let row = nested_row(data, GLOBAL_INDEX_ARITY, "global index metadata")?;
+    require_present(&row, &[0, 1, 2], "global index metadata")?;
+    Ok(GlobalIndexMeta {
+        row_range_start: row.get_long(0).map_err(nested_error)?,
+        row_range_end: row.get_long(1).map_err(nested_error)?,
+        index_field_id: row.get_int(2).map_err(nested_error)?,
+        extra_field_ids: if row.is_null_at(3) {
+            None
+        } else {
+            Some(deserialize_binary_array_int(
+                row.get_binary(3).map_err(nested_error)?,
+            )?)
+        },
+        index_meta: if row.is_null_at(4) {
+            None
+        } else {
+            Some(row.get_binary(4).map_err(nested_error)?.to_vec())
+        },
+        source_meta: if row.is_null_at(5) {
+            None
+        } else {
+            Some(row.get_binary(5).map_err(nested_error)?.to_vec())
+        },
+    })
+}
+
+fn read_deletion_vector_ranges(data: &[u8]) -> crate::Result<IndexMap<String, 
DeletionVectorMeta>> {
+    let mut ranges = IndexMap::new();
+    for element in deserialize_binary_array_rows(data)? {
+        let row = nested_row(element, DELETION_VECTOR_ARITY, "deletion vector 
metadata")?;
+        require_present(&row, &[0, 1, 2], "deletion vector metadata")?;
+        // The data file name is field 0 of the row and also the map key, the 
way
+        // Java rebuilds this map.
+        let file_name = row.get_string(0).map_err(nested_error)?.to_string();
+        let meta = DeletionVectorMeta {
+            offset: row.get_int(1).map_err(nested_error)?,
+            length: row.get_int(2).map_err(nested_error)?,
+            cardinality: if row.is_null_at(3) {
+                None
+            } else {
+                Some(row.get_long(3).map_err(nested_error)?)
+            },
+        };
+        ranges.insert(file_name, meta);
+    }
+    Ok(ranges)
+}
+
+/// Read an `i32`-framed row body off the cursor.
+fn read_nested_row(cur: &mut &[u8], arity: i32, what: &str) -> 
crate::Result<BinaryRow> {
+    let length = read_i32(cur)?;
+    if length < 0 {
+        return Err(data_invalid(format!(
+            "negative {what} row length: {length}"
+        )));
+    }
+    let length = length as usize;
+    if length > cur.len() {
+        return Err(data_invalid(format!(
+            "{what} row length {length} exceeds {} remaining bytes",
+            cur.len()
+        )));
+    }
+    nested_row(crate::table::source::take(cur, length)?, arity, what)
+}
+
+/// Reject a null in a field the schema declares NOT NULL.
+fn require_present(row: &BinaryRow, fields: &[usize], what: &str) -> 
crate::Result<()> {
+    for &field in fields {
+        if row.is_null_at(field) {
+            return Err(data_invalid(format!(
+                "{what} field {field} must not be null"
+            )));
+        }
+    }
+    Ok(())
+}
+
+/// View bytes that are already delimited as a row of the given arity.
+fn nested_row(data: &[u8], arity: i32, what: &str) -> crate::Result<BinaryRow> 
{
+    let fixed_part = BinaryRow::cal_fix_part_size_in_bytes(arity) as usize;
+    if data.len() < fixed_part {
+        return Err(data_invalid(format!(
+            "{what} row of {} bytes is shorter than its {fixed_part}-byte 
fixed part",
+            data.len()
+        )));
+    }
+    Ok(BinaryRow::from_bytes(arity, data.to_vec()))
+}
+
+/// Read an element count, bounded by the bytes that can still follow it. Every
+/// element of every repetition here costs at least four bytes, so this keeps a
+/// forged count from driving work the message could not contain.
+fn read_count(cur: &mut &[u8], element: &str) -> crate::Result<usize> {
+    let count = read_i32(cur)?;
+    if count < 0 {
+        return Err(data_invalid(format!("negative {element} count: {count}")));
+    }
+    let count = count as usize;
+    if count > cur.len() / 4 {
+        return Err(data_invalid(format!(
+            "{element} count {count} exceeds the maximum allowed by {} 
remaining bytes",
+            cur.len()
+        )));
+    }
+    Ok(count)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::PrimaryKeyIndexSourceMeta;
+
+    /// Byte-for-byte copy of what Java's `BucketVectorSearchSplit#serialize`
+    /// produces, so a change on either side that is not mirrored shows up as a
+    /// decode failure here. Dumped from `BucketVectorSearchSplitTest.split()` 
in
+    /// apache/paimon at `088d4880ff` (#9386); the Java side keeps no golden 
bytes
+    /// of its own, so a change to that builder has to be dumped here again by
+    /// hand -- this test is the only thing that notices.
+    const GOLDEN: &[u8] = 
include_bytes!("goldens/bucket_vector_search_split_v1.bin");
+
+    fn golden() -> Vec<u8> {
+        GOLDEN.to_vec()
+    }
+
+    /// Offsets into the fixture. The nested `DataSplit` is written inline and 
is
+    /// variable-length, so what follows it cannot be located by a formula over
+    /// the header; these come from the fixture's own shape, and
+    /// `fixture_offsets_are_current` fails if it changes.
+    const DATA_SPLIT_VERSION_OFFSET: usize = 8 + 4 + 8;
+    const PAYLOAD_COUNT_OFFSET: usize = 643;
+
+    /// The `DataSplit`'s data-file count, which follows its deprecated
+    /// before-files count and before-deletion-files flag.
+    const DATA_FILE_COUNT_OFFSET: usize = 80;
+
+    /// `_ROW_COUNT` inside the only data file's row: field 2 of a 21-field
+    /// `BinaryRow`, so past its 8-byte null region and two 8-byte slots. 
Written
+    /// little-endian, unlike the big-endian envelope around it.
+    const DATA_FILE_ROW_COUNT_OFFSET: usize = DATA_FILE_COUNT_OFFSET + 4 + 4 + 
8 + 2 * 8;
+
+    /// The row-range section closes the message: one modified-UTF-8 file name
+    /// (`u16` length + 10 bytes), its range count, and two `[from, to]` pairs.
+    const RANGE_SECTION_BYTES: usize = 2 + 10 + 4 + 4 * 8;
+
+    fn payload_row_length_offset() -> usize {
+        PAYLOAD_COUNT_OFFSET + 4
+    }
+
+    fn range_file_name_offset() -> usize {
+        GOLDEN.len() - RANGE_SECTION_BYTES + 2
+    }
+
+    fn range_bound_offset(index: usize) -> usize {
+        GOLDEN.len() - 4 * 8 + index * 8
+    }
+
+    fn read_i32_at(bytes: &[u8], offset: usize) -> i32 {
+        i32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
+    }
+
+    /// The offsets above are hand-derived, so check them against the fixture
+    /// rather than letting a stale one make another test pass for the wrong
+    /// reason.
+    #[test]
+    fn fixture_offsets_are_current() {
+        assert_eq!(read_i32_at(GOLDEN, DATA_SPLIT_VERSION_OFFSET), 9);
+        assert_eq!(
+            i64::from_le_bytes(
+                GOLDEN[DATA_FILE_ROW_COUNT_OFFSET..DATA_FILE_ROW_COUNT_OFFSET 
+ 8]
+                    .try_into()
+                    .unwrap()
+            ),
+            6,
+            "expected the data file's row count"
+        );
+        assert_eq!(read_i32_at(GOLDEN, PAYLOAD_COUNT_OFFSET), 1);
+        assert_eq!(read_i32_at(GOLDEN, DATA_FILE_COUNT_OFFSET), 1);
+        assert_eq!(
+            read_i32_at(GOLDEN, GOLDEN.len() - RANGE_SECTION_BYTES - 4),
+            1,
+            "expected one row-range file entry"
+        );
+        assert_eq!(
+            &GOLDEN[range_file_name_offset()..range_file_name_offset() + 10],
+            b"data-1.orc"
+        );
+    }
+
+    fn put_i32(bytes: &mut [u8], offset: usize, value: i32) {
+        bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
+    }
+
+    fn put_i64(bytes: &mut [u8], offset: usize, value: i64) {
+        bytes[offset..offset + 8].copy_from_slice(&value.to_be_bytes());
+    }
+
+    fn single_string_row(value: &str) -> Vec<u8> {
+        let mut builder = crate::spec::BinaryRowBuilder::new(1);
+        builder.write_bytes(0, value.as_bytes());
+        builder.build_serialized()
+    }
+
+    fn single_int_row(value: i32) -> Vec<u8> {
+        let mut builder = crate::spec::BinaryRowBuilder::new(1);
+        builder.write_int(0, value);
+        builder.build_serialized()
+    }
+
+    #[test]
+    fn deserialize_matches_java_golden() {
+        let split = BucketVectorSearchSplit::deserialize(GOLDEN).unwrap();
+
+        let data_split = split.data_split();
+        assert_eq!(data_split.snapshot_id(), 11);
+        assert_eq!(data_split.bucket(), 2);
+        assert_eq!(data_split.bucket_path(), "bucket-2");
+        assert_eq!(data_split.total_buckets(), 8);
+        assert!(data_split.data_deletion_files().is_none());
+        assert!(!data_split.raw_convertible());
+        // The nested rows are the part of the message most easily misread, so 
the
+        // fixture carries real ones rather than empty rows -- and they are 
read
+        // back as values, not just as bytes.
+        assert_eq!(
+            data_split.partition().to_serialized_bytes(),
+            single_int_row(20_250_826)
+        );
+        assert_eq!(data_split.partition().get_int(0).unwrap(), 20_250_826);
+
+        let files = data_split.data_files();
+        assert_eq!(files.len(), 1);
+        let file = &files[0];
+        assert_eq!(file.file_name, "data-1.orc");
+        assert_eq!(file.file_size, 1_234);
+        assert_eq!(file.row_count, 6);
+        assert_eq!(file.min_key, single_string_row("min_key"));
+        assert_eq!(file.max_key, single_string_row("max_key"));
+        for stats in [&file.key_stats, &file.value_stats] {
+            assert_eq!(stats.min_values(), &single_string_row("min_value"));
+            assert_eq!(stats.max_values(), &single_string_row("max_value"));
+            assert_eq!(stats.null_counts(), &[Some(0)]);
+        }
+        assert_eq!(file.min_sequence_number, 3);
+        assert_eq!(file.max_sequence_number, 9);
+        assert_eq!(file.schema_id, 7);
+        assert_eq!(file.level, 1);
+        assert!(file.extra_files.is_empty());
+        assert_eq!(
+            file.creation_time.unwrap().timestamp_millis(),
+            1_700_000_000_000
+        );
+        assert_eq!(file.delete_row_count, Some(0));
+        assert_eq!(file.embedded_index, None);
+        // FileSource.COMPACT
+        assert_eq!(file.file_source, Some(1));
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(file.external_path, None);
+        assert_eq!(file.first_row_id, Some(40));
+        assert_eq!(
+            file.write_cols.as_deref(),
+            Some(["k".to_string(), "v".to_string()].as_slice())
+        );
+        assert_eq!(
+            file.column_max_sequence_numbers.as_deref(),
+            Some([3i64, 9].as_slice())
+        );
+
+        assert_eq!(split.payload_files().len(), 1);
+        let payload = &split.payload_files()[0];
+        assert_eq!(payload.index_type(), "ivf-pq");
+        assert_eq!(payload.file_name(), "ann-0.idx");
+        assert_eq!(payload.file_size(), 5_000_000_000);
+        assert_eq!(payload.row_count(), 6);
+        assert_eq!(payload.deletion_vectors_ranges(), None);
+        assert_eq!(
+            payload.external_path(),
+            Some("s3://vector-bucket/ann-0.idx")
+        );
+        let global = payload.global_index_meta();
+        assert_eq!(global.row_range_start, 40);
+        assert_eq!(global.row_range_end, 45);
+        assert_eq!(global.index_field_id, 7);
+        assert_eq!(
+            global.extra_field_ids.as_deref(),
+            Some([3i32, 5].as_slice())
+        );
+        assert_eq!(global.index_meta.as_deref(), Some([1u8, 2, 3].as_slice()));
+
+        let source_meta = 
PrimaryKeyIndexSourceMeta::from_global_index_meta(global).unwrap();
+        assert_eq!(source_meta.data_level(), 1);
+        assert_eq!(source_meta.source_files().len(), 1);
+        assert_eq!(source_meta.source_files()[0].file_name(), "data-1.orc");
+        assert_eq!(source_meta.source_files()[0].row_count(), 6);
+
+        let mut expected_ranges = IndexMap::new();
+        expected_ranges.insert(
+            "data-1.orc".to_string(),
+            vec![RowRange::new(0, 1), RowRange::new(4, 5)],
+        );
+        assert_eq!(split.row_ranges_by_file(), &expected_ranges);
+    }
+
+    /// A second fixture, for what the first cannot reach: deletion vector 
ranges
+    /// (the only array-of-rows in this format), more than one payload, and a
+    /// payload with every optional the schema allows to be absent left absent.
+    ///
+    /// No committed Java builder produces this one, so its shape is recorded 
here
+    /// to keep it reproducible. It is `BucketVectorSearchSplitTest.split()`'s
+    /// `dataSplit` and `rowRangesByFile` with two payloads in place of one:
+    ///
+    /// ```java
+    /// LinkedHashMap<String, DeletionVectorMeta> dv = new LinkedHashMap<>();
+    /// dv.put("data-1.orc", new DeletionVectorMeta("data-1.orc", 0, 8, 2L));
+    /// dv.put("data-2.orc", new DeletionVectorMeta("data-2.orc", 8, 16, 
null));
+    /// new IndexFileMeta("ivf-pq", "ann-0.idx", 5_000_000_000L, 6, dv,
+    ///         "s3://vector-bucket/ann-0.idx",
+    ///         new GlobalIndexMeta(40, 45, 7, new int[] {3, 5, 9},
+    ///                 new byte[] {1, 2, 3}, sourceMeta));
+    /// new IndexFileMeta("flat", "ann-1.idx", 0, 0, null, null,
+    ///         new GlobalIndexMeta(0, 0, 1, null, null, sourceMeta));
+    /// ```
+    ///
+    /// where `sourceMeta` is the same blob both payloads carry, the one
+    /// `split()` builds: `PrimaryKeyIndexSourceMeta(1, [("data-1.orc", 6)])`.
+    const GOLDEN_DELETION_VECTORS: &[u8] =
+        
include_bytes!("goldens/bucket_vector_search_split_v1_deletion_vectors.bin");
+
+    #[test]
+    fn deserialize_matches_java_golden_with_deletion_vectors() {
+        let split = 
BucketVectorSearchSplit::deserialize(GOLDEN_DELETION_VECTORS).unwrap();
+        assert_eq!(split.payload_files().len(), 2);
+
+        // The nested DataSplit and the row ranges are the same as the other
+        // fixture's, so a change that only breaks one of the two shows up 
here.
+        assert_eq!(split.data_split().snapshot_id(), 11);
+        assert_eq!(split.data_split().data_files()[0].file_name, "data-1.orc");
+        assert_eq!(
+            split.row_ranges_by_file()["data-1.orc"],
+            vec![RowRange::new(0, 1), RowRange::new(4, 5)]
+        );
+
+        let full = &split.payload_files()[0];
+        assert_eq!(full.index_type(), "ivf-pq");
+        assert_eq!(full.file_name(), "ann-0.idx");
+        assert_eq!(full.file_size(), 5_000_000_000);
+        assert_eq!(full.row_count(), 6);
+        assert_eq!(full.external_path(), Some("s3://vector-bucket/ann-0.idx"));
+        let dv_ranges = full.deletion_vectors_ranges().unwrap();
+        assert_eq!(
+            dv_ranges.keys().collect::<Vec<_>>(),
+            ["data-1.orc", "data-2.orc"]
+        );
+        assert_eq!(
+            dv_ranges["data-1.orc"],
+            DeletionVectorMeta {
+                offset: 0,
+                length: 8,
+                cardinality: Some(2),
+            }
+        );
+        // Java writes a null cardinality; it must not come back as a value.
+        assert_eq!(
+            dv_ranges["data-2.orc"],
+            DeletionVectorMeta {
+                offset: 8,
+                length: 16,
+                cardinality: None,
+            }
+        );
+        let global = full.global_index_meta();
+        assert_eq!(global.row_range_start, 40);
+        assert_eq!(global.row_range_end, 45);
+        assert_eq!(global.index_field_id, 7);
+        assert_eq!(
+            global.extra_field_ids.as_deref(),
+            Some([3i32, 5, 9].as_slice())
+        );
+        assert_eq!(global.index_meta.as_deref(), Some([1u8, 2, 3].as_slice()));
+        // Parse each payload's own source metadata rather than comparing the 
two
+        // payloads': the fixture gives them the same blob, so a comparison 
passes
+        // even when one side is read off the wrong payload.
+        assert_source_meta(full);
+
+        let minimal = &split.payload_files()[1];
+        assert_eq!(minimal.index_type(), "flat");
+        assert_eq!(minimal.file_name(), "ann-1.idx");
+        assert_eq!(minimal.file_size(), 0);
+        assert_eq!(minimal.row_count(), 0);
+        assert_eq!(minimal.deletion_vectors_ranges(), None);
+        assert_eq!(minimal.external_path(), None);
+        let minimal_global = minimal.global_index_meta();
+        assert_eq!(minimal_global.row_range_start, 0);
+        assert_eq!(minimal_global.row_range_end, 0);
+        assert_eq!(minimal_global.index_field_id, 1);
+        assert_eq!(minimal_global.extra_field_ids, None);
+        assert_eq!(minimal_global.index_meta, None);
+        assert_source_meta(minimal);
+    }
+
+    /// Both payloads carry the same `_SOURCE_META`, so each is checked by 
parsing
+    /// its own rather than by comparing them.
+    fn assert_source_meta(payload: &BucketVectorPayload) {
+        let source = 
PrimaryKeyIndexSourceMeta::deserialize(payload.source_meta()).unwrap();
+        assert_eq!(source.data_level(), 1);
+        assert_eq!(source.source_files().len(), 1);
+        assert_eq!(source.source_files()[0].file_name(), "data-1.orc");
+        assert_eq!(source.source_files()[0].row_count(), 6);
+    }
+
+    #[test]
+    fn rejects_invalid_magic() {
+        let mut bytes = golden();
+        put_i64(&mut bytes, 0, MAGIC + 1);
+        assert_error_contains(&bytes, "invalid BucketVectorSearchSplit magic");
+    }
+
+    #[test]
+    fn rejects_unsupported_version() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, 8, VERSION + 1);
+        let error = BucketVectorSearchSplit::deserialize(&bytes).unwrap_err();
+        assert!(
+            matches!(error, crate::Error::Unsupported { .. }),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn rejects_truncated_header() {
+        assert_error_contains(&GOLDEN[..8], "underrun");
+    }
+
+    #[test]
+    fn rejects_trailing_bytes() {
+        let mut bytes = golden();
+        bytes.push(0);
+        assert_error_contains(&bytes, "1 trailing bytes after 
BucketVectorSearchSplit");
+    }
+
+    /// The nested `DataSplit` carries its own version, and one this reader 
cannot
+    /// read has to stay distinguishable from a corrupt message.
+    #[test]
+    fn rejects_unsupported_nested_data_split_version() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, DATA_SPLIT_VERSION_OFFSET, 99);
+        let error = BucketVectorSearchSplit::deserialize(&bytes).unwrap_err();
+        assert!(
+            matches!(error, crate::Error::Unsupported { .. }),
+            "unexpected error: {error}"
+        );
+    }
+
+    /// A malformed nested structure is invalid data, not an internal fault:
+    /// callers that treat the two differently have to be able to tell them 
apart.
+    #[test]
+    fn reports_a_malformed_nested_row_as_invalid_data() {
+        let mut bytes = golden();
+        // Shrink the payload row so its fixed part no longer fits.
+        put_i32(&mut bytes, payload_row_length_offset(), 4);
+        let error = BucketVectorSearchSplit::deserialize(&bytes).unwrap_err();
+        assert!(
+            matches!(error, crate::Error::DataInvalid { .. }),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn rejects_negative_payload_row_length() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, payload_row_length_offset(), -1);
+        assert_error_contains(&bytes, "negative payload row length: -1");
+    }
+
+    #[test]
+    fn rejects_payload_row_longer_than_the_message() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, payload_row_length_offset(), i32::MAX);
+        assert_error_contains(&bytes, "payload row length");
+    }
+
+    #[test]
+    fn rejects_negative_payload_count() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, PAYLOAD_COUNT_OFFSET, -1);
+        assert_error_contains(&bytes, "negative payload file count: -1");
+    }
+
+    /// A count is only trusted up to what the remaining bytes could hold, so 
an
+    /// inflated one is rejected instead of driving work the message cannot
+    /// contain.
+    #[test]
+    fn rejects_payload_count_larger_than_the_message() {
+        let mut bytes = golden();
+        put_i32(&mut bytes, PAYLOAD_COUNT_OFFSET, i32::MAX);
+        assert_error_contains(&bytes, "payload file count");
+    }
+
+    #[test]
+    fn rejects_descending_row_range() {
+        let mut bytes = golden();
+        put_i64(&mut bytes, range_bound_offset(0), 42);
+        assert_error_contains(&bytes, "invalid row range [42, 1] for file 
data-1.orc");
+    }
+
+    #[test]
+    fn rejects_row_range_past_the_end_of_its_file() {
+        let mut bytes = golden();
+        put_i64(&mut bytes, range_bound_offset(3), 6);
+        assert_error_contains(&bytes, "is outside [0, 6)");
+    }
+
+    #[test]
+    fn rejects_row_ranges_for_an_unknown_data_file() {
+        let mut bytes = golden();
+        let offset = range_file_name_offset();
+        assert_eq!(&bytes[offset..offset + 10], b"data-1.orc");
+        bytes[offset..offset + 10].copy_from_slice(b"data-2.orc");
+        assert_error_contains(
+            &bytes,
+            "row ranges reference data file not present in the bucket split: 
data-2.orc",
+        );
+    }
+
+    /// Row ranges key on the file name, so the names have to identify one file
+    /// each for the mapping to mean anything.
+    #[test]
+    fn rejects_duplicate_data_files() {
+        let mut bytes = golden();
+        // Point the second data file entry at the first file's row by cloning 
it:
+        // simplest here is to rewrite the count and append a copy of the row.
+        let row_start = DATA_FILE_COUNT_OFFSET + 4;
+        let row_length = read_i32_at(&bytes, row_start) as usize;
+        let row = bytes[row_start..row_start + 4 + row_length].to_vec();
+        put_i32(&mut bytes, DATA_FILE_COUNT_OFFSET, 2);
+        let insert_at = row_start + 4 + row_length;
+        bytes.splice(insert_at..insert_at, row);
+        assert_error_contains(
+            &bytes,
+            "duplicate data file in the bucket split: data-1.orc",
+        );
+    }
+
+    /// A null in a field the schema declares NOT NULL is zeroed rather than
+    /// absent, so it has to be caught by the bit and not by the value.
+    #[test]
+    fn rejects_a_null_in_a_required_payload_field() {
+        let mut bytes = golden();
+        // Field 0 of the payload row: its null bit is bit 8, the first bit of 
the
+        // second byte of the row's null region.
+        let row = payload_row_length_offset() + 4;
+        bytes[row + 1] |= 1;
+        assert_error_contains(&bytes, "payload field 0 must not be null");
+    }
+
+    /// A forged row count must not be usable to lift the range bound.
+    #[test]
+    fn rejects_a_negative_data_file_row_count() {
+        let mut bytes = golden();
+        bytes[DATA_FILE_ROW_COUNT_OFFSET..DATA_FILE_ROW_COUNT_OFFSET + 8]
+            .copy_from_slice(&(-1i64).to_le_bytes());
+        assert_error_contains(&bytes, "data file data-1.orc has a negative row 
count: -1");
+    }
+
+    #[test]
+    fn rejects_negative_row_range() {
+        let mut bytes = golden();
+        put_i64(&mut bytes, range_bound_offset(0), -1);
+        assert_error_contains(&bytes, "negative row range [-1, 1] for file 
data-1.orc");
+    }
+
+    fn assert_error_contains(bytes: &[u8], expected: &str) {
+        let error = BucketVectorSearchSplit::deserialize(bytes).unwrap_err();
+        let message = error.to_string();
+        assert!(
+            message.contains(expected),
+            "expected {expected:?} in {message:?}"
+        );
+    }
+}
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 38bfdb89..aaaf66cf 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -729,7 +729,7 @@ impl DataSplit {
 
     /// Read a `DataSplit` body from the cursor, leaving it positioned after 
the body
     /// (used both by `deserialize` and the SPLIT_V1 frame reader).
-    fn read_body(cur: &mut &[u8]) -> crate::Result<DataSplit> {
+    pub(super) fn read_body(cur: &mut &[u8]) -> crate::Result<DataSplit> {
         let magic = read_i64(cur)?;
         if magic != SPLIT_MAGIC {
             return Err(crate::Error::DataInvalid {
@@ -1041,10 +1041,10 @@ fn write_deletion_list(
 }
 
 /// Advances `cur` by `n` bytes, returning the consumed slice. Errors on 
underrun.
-fn take<'a>(cur: &mut &'a [u8], n: usize) -> crate::Result<&'a [u8]> {
+pub(super) fn take<'a>(cur: &mut &'a [u8], n: usize) -> crate::Result<&'a 
[u8]> {
     if cur.len() < n {
         return Err(crate::Error::DataInvalid {
-            message: format!("split buffer underrun: need {n}, have {}", 
cur.len()),
+            message: format!("buffer underrun: need {n}, have {}", cur.len()),
             source: None,
         });
     }
@@ -1061,11 +1061,11 @@ fn read_i16(cur: &mut &[u8]) -> crate::Result<i16> {
     Ok(i16::from_be_bytes(take(cur, 2)?.try_into().unwrap()))
 }
 
-fn read_i32(cur: &mut &[u8]) -> crate::Result<i32> {
+pub(super) fn read_i32(cur: &mut &[u8]) -> crate::Result<i32> {
     Ok(i32::from_be_bytes(take(cur, 4)?.try_into().unwrap()))
 }
 
-fn read_i64(cur: &mut &[u8]) -> crate::Result<i64> {
+pub(super) fn read_i64(cur: &mut &[u8]) -> crate::Result<i64> {
     Ok(i64::from_be_bytes(take(cur, 8)?.try_into().unwrap()))
 }
 
@@ -1079,7 +1079,7 @@ fn utf_err() -> crate::Error {
 /// Reverse of [`write_java_utf`]: `u16` byte-length prefix + modified UTF-8. 
Each UTF-16 code
 /// unit is encoded independently, so a supplementary char arrives as two 
3-byte surrogate units;
 /// collect the raw `u16` units and let [`String::from_utf16`] pair the 
surrogates.
-fn read_java_utf(cur: &mut &[u8]) -> crate::Result<String> {
+pub(super) fn read_java_utf(cur: &mut &[u8]) -> crate::Result<String> {
     let len = read_i16(cur)? as u16 as usize;
     let bytes = take(cur, len)?;
     let mut units: Vec<u16> = Vec::new();

Reply via email to