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 f0cbb845 feat: deserialize DataSplit from Paimon-native bytes (core + 
C binding) (#565)
f0cbb845 is described below

commit f0cbb84519b0cf358e6b0997bf2be3d238550914
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Jul 21 20:51:03 2026 +0800

    feat: deserialize DataSplit from Paimon-native bytes (core + C binding) 
(#565)
---
 bindings/c/src/table.rs              |  53 ++-
 bindings/c/src/tests.rs              |  56 ++++
 crates/paimon/src/spec/binary_row.rs | 153 +++++++++
 crates/paimon/src/spec/data_file.rs  | 169 ++++++++++
 crates/paimon/src/spec/stats.rs      |  19 ++
 crates/paimon/src/table/source.rs    | 632 ++++++++++++++++++++++++++++++++++-
 6 files changed, 1077 insertions(+), 5 deletions(-)

diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index a17ba3fc..05f82e31 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -22,7 +22,7 @@ use arrow_array::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
 use arrow_array::{Array, StructArray};
 use futures::StreamExt;
 use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder};
-use paimon::table::{ArrowRecordBatchStream, Table};
+use paimon::table::{ArrowRecordBatchStream, DataSplit, Table};
 use paimon::Plan;
 
 use crate::error::{check_non_null, paimon_error, validate_cstr, 
PaimonErrorCode};
@@ -472,10 +472,54 @@ pub unsafe extern "C" fn paimon_table_scan_plan(
 
 // ======================= Plan ===============================
 
+/// Build a one-split `paimon_plan` from a serialized Paimon-native `DataSplit`
+/// byte buffer (the wire form produced by `DataSplit::serialize` / Java
+/// `DataSplit#serialize`). `data` must be raw bytes (Base64 already decoded by
+/// the caller).
+///
+/// The returned plan is usable with `paimon_table_read_to_arrow` and must be
+/// freed with `paimon_plan_free`.
+///
+/// # Safety
+/// `data` must point to `len` valid bytes, or be null when `len == 0`.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_plan_from_split_bytes(
+    data: *const u8,
+    len: usize,
+) -> paimon_result_plan {
+    if data.is_null() || len == 0 {
+        return paimon_result_plan {
+            plan: std::ptr::null_mut(),
+            error: paimon_error::new(
+                PaimonErrorCode::InvalidInput,
+                "paimon_plan_from_split_bytes: null or empty 
buffer".to_string(),
+            ),
+        };
+    }
+    let bytes = std::slice::from_raw_parts(data, len);
+    match DataSplit::deserialize(bytes) {
+        Ok(split) => {
+            let plan = Plan::new(vec![split]);
+            let wrapper = Box::new(paimon_plan {
+                inner: Box::into_raw(Box::new(plan)) as *mut c_void,
+            });
+            paimon_result_plan {
+                plan: Box::into_raw(wrapper),
+                error: std::ptr::null_mut(),
+            }
+        }
+        Err(e) => paimon_result_plan {
+            plan: std::ptr::null_mut(),
+            error: paimon_error::from_paimon(e),
+        },
+    }
+}
+
 /// Free a paimon_plan.
 ///
 /// # Safety
 /// Only call with a plan returned from `paimon_table_scan_plan`.
+/// A plan returned from `paimon_plan_from_split_bytes` is also a valid source.
 #[no_mangle]
 pub unsafe extern "C" fn paimon_plan_free(plan: *mut paimon_plan) {
     if !plan.is_null() {
@@ -490,6 +534,7 @@ pub unsafe extern "C" fn paimon_plan_free(plan: *mut 
paimon_plan) {
 ///
 /// # Safety
 /// `plan` must be a valid pointer from `paimon_table_scan_plan`, or null 
(returns 0).
+/// A plan returned from `paimon_plan_from_split_bytes` is also a valid source.
 #[no_mangle]
 pub unsafe extern "C" fn paimon_plan_num_splits(plan: *const paimon_plan) -> 
usize {
     if plan.is_null() {
@@ -1736,6 +1781,12 @@ const _: unsafe extern "C" fn(
     usize,
 ) -> paimon_result_read_builder = paimon_table_new_read_builder_with_options;
 
+// Plan constructor ABI signature guard. Pins the symbol that builds a plan 
from
+// serialized split bytes so an accidental signature change fails to compile
+// rather than silently breaking header consumers.
+const _: unsafe extern "C" fn(*const u8, usize) -> paimon_result_plan =
+    paimon_plan_from_split_bytes;
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 2ced3245..3897189c 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -2418,3 +2418,59 @@ fn vector_search_empty_result_is_eof_stream() {
         unwrap_table(handle);
     }
 }
+
+// =========================================================================
+//  paimon_plan_from_split_bytes
+// =========================================================================
+
+#[test]
+fn plan_from_split_bytes_round_trips() {
+    let path = "memory:/test_plan_from_split_bytes";
+    let file_io = memory_file_io();
+    setup_table_dirs(&file_io, path);
+    let table = Table::new(
+        file_io.clone(),
+        Identifier::new("default", "test"),
+        path.to_string(),
+        simple_table_schema(),
+        None,
+    );
+    write_data_rust(&table, &[make_batch(vec![1, 2, 3], vec!["a", "b", "c"])]);
+
+    // Obtain a real DataSplit from a plan via the Rust API, then serialize it.
+    let splits = crate::runtime().block_on(async {
+        let rb = table.new_read_builder();
+        let scan = rb.new_scan();
+        scan.plan().await.unwrap().splits().to_vec()
+    });
+    assert!(!splits.is_empty(), "expected at least one planned split");
+    let bytes = splits[0].serialize().unwrap();
+
+    let result = unsafe { paimon_plan_from_split_bytes(bytes.as_ptr(), 
bytes.len()) };
+    assert!(result.error.is_null());
+    assert_eq!(unsafe { paimon_plan_num_splits(result.plan) }, 1);
+    unsafe { paimon_plan_free(result.plan) };
+}
+
+#[test]
+fn plan_from_split_bytes_rejects_null_and_empty() {
+    let r = unsafe { paimon_plan_from_split_bytes(std::ptr::null(), 0) };
+    assert!(r.plan.is_null());
+    assert!(!r.error.is_null());
+    unsafe { paimon_error_free(r.error) };
+
+    let dummy = [0u8; 1];
+    let r2 = unsafe { paimon_plan_from_split_bytes(dummy.as_ptr(), 0) };
+    assert!(r2.plan.is_null());
+    assert!(!r2.error.is_null());
+    unsafe { paimon_error_free(r2.error) };
+}
+
+#[test]
+fn plan_from_split_bytes_rejects_garbage() {
+    let garbage = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+    let r = unsafe { paimon_plan_from_split_bytes(garbage.as_ptr(), 
garbage.len()) };
+    assert!(r.plan.is_null());
+    assert!(!r.error.is_null());
+    unsafe { paimon_error_free(r.error) };
+}
diff --git a/crates/paimon/src/spec/binary_row.rs 
b/crates/paimon/src/spec/binary_row.rs
index 4a4017f3..ce08ad8b 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -757,6 +757,104 @@ pub fn serialize_binary_array_long(values: 
&[Option<i64>]) -> Vec<u8> {
     data
 }
 
+/// Reverse of [`serialize_binary_array_str`].
+pub fn deserialize_binary_array_str(data: &[u8]) -> crate::Result<Vec<String>> 
{
+    let n = read_binary_array_len(data)?;
+    let header = binary_array_header(n);
+    // The fixed element region is `n * 8` bytes after the header; reject any
+    // count whose slots cannot fit in the buffer up front. This bounds both 
the
+    // reservation and the loop, so a forged large count (with or without
+    // element slots) cannot amplify memory before per-element validation.
+    check_binary_array_fits(n, header, data.len())?;
+    let mut out = Vec::with_capacity(n);
+    for k in 0..n {
+        let eo = header + k * 8;
+        let slot = data
+            .get(eo..eo + 8)
+            .ok_or_else(|| bin_arr_err("string element slot out of range"))?;
+        let marker = slot[7];
+        let bytes = if marker & 0x80 != 0 {
+            let len = (marker & 0x7F) as usize;
+            slot.get(..len)
+                .ok_or_else(|| bin_arr_err("inline string length out of 
range"))?
+        } else {
+            let encoded = u64::from_le_bytes(slot.try_into().unwrap());
+            let var_off = (encoded >> 32) as usize;
+            let len = (encoded & 0xFFFF_FFFF) as usize;
+            let end = var_off
+                .checked_add(len)
+                .ok_or_else(|| bin_arr_err("variable string bytes out of 
range"))?;
+            data.get(var_off..end)
+                .ok_or_else(|| bin_arr_err("variable string bytes out of 
range"))?
+        };
+        out.push(
+            std::str::from_utf8(bytes)
+                .map_err(|_| bin_arr_err("string element is not valid UTF-8"))?
+                .to_string(),
+        );
+    }
+    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)?;
+    let header = binary_array_header(n);
+    // See `deserialize_binary_array_str`: reject a count whose fixed element
+    // region overflows the buffer before allocating. Null elements skip the
+    // per-slot read, so this up-front check is what prevents a forged
+    // "large count + all-null bitmap + no slots" input from amplifying memory.
+    check_binary_array_fits(n, header, data.len())?;
+    let mut out = Vec::with_capacity(n);
+    for k in 0..n {
+        let null = data
+            .get(4 + k / 8)
+            .map(|b| b & (1 << (k % 8)) != 0)
+            .unwrap_or(false);
+        if null {
+            out.push(None);
+        } else {
+            let eo = header + k * 8;
+            let slot = data
+                .get(eo..eo + 8)
+                .ok_or_else(|| bin_arr_err("long element slot out of range"))?;
+            out.push(Some(i64::from_le_bytes(slot.try_into().unwrap())));
+        }
+    }
+    Ok(out)
+}
+
+fn read_binary_array_len(data: &[u8]) -> crate::Result<usize> {
+    let raw = data
+        .get(0..4)
+        .ok_or_else(|| bin_arr_err("binary array too short for length 
prefix"))?;
+    let n = i32::from_le_bytes(raw.try_into().unwrap());
+    if n < 0 {
+        return Err(bin_arr_err("binary array has negative length"));
+    }
+    Ok(n as usize)
+}
+
+/// Reject a binary array whose `n` fixed 8-byte element slots cannot fit in 
the
+/// buffer after its `header`. Computed without overflow so a forged count
+/// cannot wrap; guards allocation and iteration for both decoders (`None`
+/// elements otherwise skip the per-slot bounds check).
+fn check_binary_array_fits(n: usize, header: usize, data_len: usize) -> 
crate::Result<()> {
+    if n > data_len.saturating_sub(header) / 8 {
+        return Err(bin_arr_err(
+            "binary array element region exceeds buffer length",
+        ));
+    }
+    Ok(())
+}
+
+fn bin_arr_err(msg: &str) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: msg.to_string(),
+        source: None,
+    }
+}
+
 /// Extract a Datum from an Arrow RecordBatch column at the given row index.
 pub fn extract_datum_from_arrow(
     batch: &RecordBatch,
@@ -1917,4 +2015,59 @@ mod tests {
             "binary-row write path must store euclidean timestamp parts"
         );
     }
+
+    #[test]
+    fn binary_array_str_round_trips() {
+        for v in [
+            vec![],
+            vec!["a".to_string()],
+            vec!["".to_string(), "short".to_string(), "x".repeat(20)],
+            vec!["1234567".to_string(), "12345678".to_string()], // 7-byte 
inline vs 8-byte pointer
+        ] {
+            let bytes = serialize_binary_array_str(&v);
+            assert_eq!(deserialize_binary_array_str(&bytes).unwrap(), v);
+        }
+    }
+
+    #[test]
+    fn binary_array_long_round_trips() {
+        for v in [
+            vec![],
+            vec![Some(1i64), None, Some(-5), Some(i64::MAX)],
+            vec![None, None],
+        ] {
+            let bytes = serialize_binary_array_long(&v);
+            assert_eq!(deserialize_binary_array_long(&bytes).unwrap(), v);
+        }
+    }
+
+    #[test]
+    fn binary_array_str_rejects_truncated() {
+        assert!(deserialize_binary_array_str(&[1, 0]).is_err()); // < 4 header 
bytes
+    }
+
+    #[test]
+    fn binary_array_rejects_huge_length_prefix() {
+        // A 4-byte buffer whose length prefix decodes to i32::MAX must return 
an
+        // Err rather than eagerly reserving a huge Vec (capacity-overflow / 
OOM).
+        let huge = [0xFF, 0xFF, 0xFF, 0x7F];
+        assert!(deserialize_binary_array_str(&huge).is_err());
+        assert!(deserialize_binary_array_long(&huge).is_err());
+    }
+
+    #[test]
+    fn binary_array_long_rejects_all_null_amplification() {
+        // Forged input: a large element count with an all-ones null bitmap and
+        // NO element slots. Null elements skip the per-slot bounds check, so
+        // without an up-front `count * 8 <= remaining` guard the loop would
+        // push `count` `None`s from a tiny buffer (~128x memory 
amplification),
+        // reachable through the C entry point (OOM risk). Must error, not
+        // allocate.
+        let count: i32 = 8000;
+        let bitmap_len = (count as usize).div_ceil(8); // 1000 bytes
+        let mut buf = Vec::with_capacity(4 + bitmap_len);
+        buf.extend_from_slice(&count.to_le_bytes());
+        buf.extend(std::iter::repeat_n(0xFFu8, bitmap_len)); // every element 
null
+        assert!(deserialize_binary_array_long(&buf).is_err());
+    }
 }
diff --git a/crates/paimon/src/spec/data_file.rs 
b/crates/paimon/src/spec/data_file.rs
index c6745ad3..43a23b43 100644
--- a/crates/paimon/src/spec/data_file.rs
+++ b/crates/paimon/src/spec/data_file.rs
@@ -169,6 +169,24 @@ fn opt_str_array(b: &mut BinaryRowBuilder, pos: usize, v: 
&Option<Vec<String>>)
     }
 }
 
+fn data_file_err(msg: &str) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: msg.to_string(),
+        source: None,
+    }
+}
+
+/// Reverse of `BinaryRowBuilder::write_timestamp_compact`: read the compact
+/// (precision-3) timestamp at `pos` back as epoch-millis and map it to `Utc`.
+fn read_compact_millis_as_utc(
+    row: &BinaryRow,
+    pos: usize,
+) -> crate::Result<chrono::DateTime<chrono::Utc>> {
+    let (millis, _nanos) = row.get_timestamp_raw(pos, 3)?;
+    chrono::DateTime::from_timestamp_millis(millis)
+        .ok_or_else(|| data_file_err("creation_time out of range"))
+}
+
 impl DataFileMeta {
     /// Decode this file's manifest value statistics for a field in the 
current schema.
     ///
@@ -288,6 +306,93 @@ impl DataFileMeta {
         Ok(b.build_row_data())
     }
 
+    /// Reverse of [`DataFileMeta::to_serialized_row_data`]: decode the fixed
+    /// 20-field `DataFileMeta` BinaryRow (version 8 layout).
+    pub fn from_serialized_row_data(data: &[u8]) -> 
crate::Result<DataFileMeta> {
+        use crate::spec::deserialize_binary_array_str;
+        let row = BinaryRow::from_bytes(20, data.to_vec());
+
+        let file_name = String::from_utf8(row.get_binary(0)?.to_vec())
+            .map_err(|_| data_file_err("file_name is not valid UTF-8"))?;
+        let file_size = row.get_long(1)?;
+        let row_count = row.get_long(2)?;
+        let min_key = row.get_binary(3)?.to_vec();
+        let max_key = row.get_binary(4)?.to_vec();
+        let key_stats = 
BinaryTableStats::from_simple_stats_row_data(row.get_binary(5)?)?;
+        let value_stats = 
BinaryTableStats::from_simple_stats_row_data(row.get_binary(6)?)?;
+        let min_sequence_number = row.get_long(7)?;
+        let max_sequence_number = row.get_long(8)?;
+        let schema_id = row.get_long(9)?;
+        let level = row.get_int(10)?;
+        let extra_files = deserialize_binary_array_str(row.get_binary(11)?)?;
+        let creation_time = if row.is_null_at(12) {
+            None
+        } else {
+            Some(read_compact_millis_as_utc(&row, 12)?)
+        };
+        let delete_row_count = if row.is_null_at(13) {
+            None
+        } else {
+            Some(row.get_long(13)?)
+        };
+        let embedded_index = if row.is_null_at(14) {
+            None
+        } else {
+            Some(row.get_binary(14)?.to_vec())
+        };
+        let file_source = if row.is_null_at(15) {
+            None
+        } else {
+            Some(row.get_byte(15)? as i32)
+        };
+        let value_stats_cols = if row.is_null_at(16) {
+            None
+        } else {
+            Some(deserialize_binary_array_str(row.get_binary(16)?)?)
+        };
+        let external_path = if row.is_null_at(17) {
+            None
+        } else {
+            Some(
+                String::from_utf8(row.get_binary(17)?.to_vec())
+                    .map_err(|_| data_file_err("external_path is not valid 
UTF-8"))?,
+            )
+        };
+        let first_row_id = if row.is_null_at(18) {
+            None
+        } else {
+            Some(row.get_long(18)?)
+        };
+        let write_cols = if row.is_null_at(19) {
+            None
+        } else {
+            Some(deserialize_binary_array_str(row.get_binary(19)?)?)
+        };
+
+        Ok(DataFileMeta {
+            file_name,
+            file_size,
+            row_count,
+            min_key,
+            max_key,
+            key_stats,
+            value_stats,
+            min_sequence_number,
+            max_sequence_number,
+            schema_id,
+            level,
+            extra_files,
+            creation_time,
+            delete_row_count,
+            embedded_index,
+            file_source,
+            value_stats_cols,
+            external_path,
+            first_row_id,
+            write_cols,
+        })
+    }
+
     /// Full path for this data file.
     ///
     /// Mirrors Java `DataFilePathFactory#toPath(DataFileMeta)`: use
@@ -415,6 +520,70 @@ mod tests {
         DataField::new(id, name.to_string(), DataType::Int(IntType::new()))
     }
 
+    fn sample_full_data_file_meta() -> DataFileMeta {
+        let stats = BinaryTableStats::empty();
+        DataFileMeta {
+            file_name: "data-full.parquet".to_string(),
+            file_size: 1024,
+            row_count: 42,
+            min_key: vec![1, 2, 3],
+            max_key: vec![9, 8, 7],
+            key_stats: stats.clone(),
+            value_stats: stats,
+            min_sequence_number: 10,
+            max_sequence_number: 20,
+            schema_id: 5,
+            level: 3,
+            extra_files: vec!["extra-1".to_string(), "extra-2".to_string()],
+            creation_time: DateTime::from_timestamp_millis(1_700_000_000_123),
+            delete_row_count: Some(4),
+            embedded_index: Some(vec![7, 8, 9]),
+            file_source: Some(0),
+            value_stats_cols: Some(vec!["a".to_string(), "b".to_string()]),
+            external_path: Some("s3://bucket/data-full.parquet".to_string()),
+            first_row_id: Some(1_000),
+            write_cols: Some(vec!["a".to_string(), "b".to_string(), 
"c".to_string()]),
+        }
+    }
+
+    fn sample_minimal_data_file_meta() -> DataFileMeta {
+        let stats = BinaryTableStats::empty();
+        DataFileMeta {
+            file_name: "data-min.parquet".to_string(),
+            file_size: 0,
+            row_count: 0,
+            min_key: Vec::new(),
+            max_key: Vec::new(),
+            key_stats: stats.clone(),
+            value_stats: stats,
+            min_sequence_number: 0,
+            max_sequence_number: 0,
+            schema_id: 0,
+            level: 0,
+            extra_files: Vec::new(),
+            creation_time: None,
+            delete_row_count: None,
+            embedded_index: None,
+            file_source: None,
+            value_stats_cols: None,
+            external_path: None,
+            first_row_id: None,
+            write_cols: None,
+        }
+    }
+
+    #[test]
+    fn data_file_meta_row_data_round_trips() {
+        for meta in [
+            sample_full_data_file_meta(),
+            sample_minimal_data_file_meta(),
+        ] {
+            let bytes = meta.to_serialized_row_data().unwrap();
+            let back = DataFileMeta::from_serialized_row_data(&bytes).unwrap();
+            assert_eq!(back, meta);
+        }
+    }
+
     #[test]
     fn value_stats_for_field_resolves_dense_columns_and_schema() {
         let fields = vec![int_field(0, "id"), int_field(1, "v")];
diff --git a/crates/paimon/src/spec/stats.rs b/crates/paimon/src/spec/stats.rs
index 5503a6f7..7660366a 100644
--- a/crates/paimon/src/spec/stats.rs
+++ b/crates/paimon/src/spec/stats.rs
@@ -123,6 +123,17 @@ impl BinaryTableStats {
         b.write_bytes(2, &serialize_binary_array_long(&self.null_counts));
         b.build_row_data()
     }
+
+    /// Reverse of [`BinaryTableStats::to_simple_stats_row_data`]: a 3-field
+    /// `SimpleStats` BinaryRow body (min_values bytes, max_values bytes,
+    /// null_counts array<bigint>).
+    pub fn from_simple_stats_row_data(data: &[u8]) -> 
crate::Result<BinaryTableStats> {
+        let row = crate::spec::BinaryRow::from_bytes(3, data.to_vec());
+        let min_values = row.get_binary(0)?.to_vec();
+        let max_values = row.get_binary(1)?.to_vec();
+        let null_counts = 
crate::spec::deserialize_binary_array_long(row.get_binary(2)?)?;
+        Ok(BinaryTableStats::new(min_values, max_values, null_counts))
+    }
 }
 
 impl Display for BinaryTableStats {
@@ -217,4 +228,12 @@ mod tests {
         assert_eq!(min_row.arity(), 0);
         assert_eq!(max_row.arity(), 0);
     }
+
+    #[test]
+    fn simple_stats_row_data_round_trips() {
+        let stats = BinaryTableStats::empty();
+        let bytes = stats.to_simple_stats_row_data();
+        let back = 
BinaryTableStats::from_simple_stats_row_data(&bytes).unwrap();
+        assert_eq!(back.to_simple_stats_row_data(), bytes);
+    }
 }
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 49ab30eb..e852630f 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -471,7 +471,7 @@ impl PartitionBucket {
 /// Input split for reading: partition + bucket + list of data files and 
optional deletion files.
 ///
 /// Reference: 
[org.apache.paimon.table.source.DataSplit](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java)
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct DataSplit {
     snapshot_id: i64,
     partition: BinaryRow,
@@ -664,6 +664,126 @@ impl DataSplit {
         Ok(out)
     }
 
+    /// Reverse of [`DataSplit::serialize`]: parse a raw v8 
`DataSplit#serialize` body.
+    /// Consumes the entire buffer; trailing bytes are an error.
+    pub fn deserialize(data: &[u8]) -> crate::Result<DataSplit> {
+        let mut cur = data;
+        let split = Self::read_v8_body(&mut cur)?;
+        if !cur.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: format!("{} trailing bytes after DataSplit", 
cur.len()),
+                source: None,
+            });
+        }
+        Ok(split)
+    }
+
+    /// Read a v8 `DataSplit` body from the cursor, leaving it positioned 
after the body
+    /// (used both by `deserialize` and the SPLIT_V1 frame reader).
+    fn read_v8_body(cur: &mut &[u8]) -> crate::Result<DataSplit> {
+        let magic = read_i64(cur)?;
+        if magic != SPLIT_MAGIC {
+            return Err(crate::Error::DataInvalid {
+                message: format!("invalid DataSplit magic: {magic:#018x}"),
+                source: None,
+            });
+        }
+        match read_i32(cur)? {
+            SPLIT_VERSION => Self::read_v8_body_after_version(cur),
+            version => Err(crate::Error::Unsupported {
+                message: format!(
+                    "DataSplit version {version} not supported (only 
v{SPLIT_VERSION})"
+                ),
+            }),
+        }
+    }
+
+    /// Read the fields following the magic + version header of a v8 
`DataSplit` body.
+    fn read_v8_body_after_version(cur: &mut &[u8]) -> crate::Result<DataSplit> 
{
+        let snapshot_id = read_i64(cur)?;
+
+        let part_len = read_i32(cur)?;
+        if part_len < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "negative partition length".into(),
+                source: None,
+            });
+        }
+        let part_bytes = take(cur, part_len as usize)?;
+        let partition = BinaryRow::from_serialized_bytes(part_bytes)?;
+
+        let bucket = read_i32(cur)?;
+        let bucket_path = read_java_utf(cur)?;
+
+        let total_buckets = if read_u8(cur)? == 0 {
+            None
+        } else {
+            Some(read_i32(cur)?)
+        };
+
+        let before_files_n = read_i32(cur)?; // deprecated, always 0 on write
+        if before_files_n != 0 {
+            return Err(crate::Error::Unsupported {
+                message: format!("non-empty beforeFiles ({before_files_n}) not 
supported"),
+            });
+        }
+        // `beforeDeletionFiles` is always a null list on write; a non-null 
list
+        // is rejected rather than silently dropped (Java treats such a split 
as
+        // invalid), matching the `beforeFiles` handling above.
+        if read_deletion_list(cur)?.is_some() {
+            return Err(crate::Error::Unsupported {
+                message: "non-null beforeDeletionFiles not 
supported".to_string(),
+            });
+        }
+
+        let data_files_n = read_i32(cur)?;
+        if data_files_n < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "negative data_files count".into(),
+                source: None,
+            });
+        }
+        // Bound the eager reservation by the remaining bytes: each entry is 
at least a
+        // 4-byte length prefix, so a crafted huge count cannot reserve more 
than the
+        // buffer could ever hold. Underrun is still caught per-element below.
+        let mut data_files = Vec::with_capacity((data_files_n as 
usize).min(cur.len() / 4));
+        for _ in 0..data_files_n {
+            let len = read_i32(cur)?;
+            if len < 0 {
+                return Err(crate::Error::DataInvalid {
+                    message: "negative DataFileMeta length".into(),
+                    source: None,
+                });
+            }
+            let row = take(cur, len as usize)?;
+            data_files.push(DataFileMeta::from_serialized_row_data(row)?);
+        }
+
+        let data_deletion_files = read_deletion_list(cur)?;
+        // Rust only produces and serves batch splits (isStreaming = false); a
+        // streaming split carries a semantic bit that Java readers branch on, 
so
+        // reject it rather than silently dropping it.
+        if read_u8(cur)? != 0 {
+            return Err(crate::Error::Unsupported {
+                message: "streaming DataSplit (isStreaming = true) not 
supported".to_string(),
+            });
+        }
+        let raw_convertible = read_u8(cur)? != 0;
+
+        let mut builder = DataSplitBuilder::new()
+            .with_snapshot(snapshot_id)
+            .with_partition(partition)
+            .with_bucket(bucket)
+            .with_bucket_path(bucket_path)
+            .with_total_buckets(total_buckets.unwrap_or(1))
+            .with_data_files(data_files)
+            .with_raw_convertible(raw_convertible);
+        if let Some(dels) = data_deletion_files {
+            builder = builder.with_data_deletion_files(dels);
+        }
+        builder.build()
+    }
+
     /// Java `SplitSerializer#serialize` frame: magic + version + type id + 
body. The cross-language
     /// entry point. A plain split serializes as `DataSplit` (type 1); a split 
carrying row ranges as
     /// `IndexedSplit` (type 3) wrapping the DataSplit body plus the ranges. 
Byte-compatible with
@@ -693,6 +813,88 @@ impl DataSplit {
         }
         Ok(out)
     }
+
+    /// Reverse of [`DataSplit::serialize_split_v1`]: parse a Java 
`SplitSerializer` frame into a
+    /// `DataSplit` (type 1) or an `IndexedSplit` (type 3, which carries 
`row_ranges`). Other split
+    /// type ids, and IndexedSplit vector scores, are unsupported. Consumes 
the entire buffer.
+    pub fn deserialize_split_v1(data: &[u8]) -> crate::Result<DataSplit> {
+        let mut cur = data;
+        let magic = read_i64(&mut cur)?;
+        if magic != SPLIT_SER_MAGIC {
+            return Err(crate::Error::DataInvalid {
+                message: format!("invalid SPLIT_V1 magic: {magic:#018x}"),
+                source: None,
+            });
+        }
+        let version = read_i32(&mut cur)?;
+        if version != SPLIT_SER_VERSION {
+            return Err(crate::Error::Unsupported {
+                message: format!("SplitSerializer version {version} not 
supported"),
+            });
+        }
+        let type_id = read_i32(&mut cur)?;
+        let split = match type_id {
+            SPLIT_SER_TYPE_DATA_SPLIT => Self::read_v8_body(&mut cur)?,
+            SPLIT_SER_TYPE_INDEXED_SPLIT => {
+                let im = read_i64(&mut cur)?;
+                if im != INDEXED_SPLIT_MAGIC {
+                    return Err(crate::Error::DataInvalid {
+                        message: format!("invalid IndexedSplit magic: 
{im:#018x}"),
+                        source: None,
+                    });
+                }
+                let iv = read_i32(&mut cur)?;
+                if iv != INDEXED_SPLIT_VERSION {
+                    return Err(crate::Error::Unsupported {
+                        message: format!("IndexedSplit version {iv} not 
supported"),
+                    });
+                }
+                let body = Self::read_v8_body(&mut cur)?;
+                let ranges_n = read_i32(&mut cur)?;
+                if ranges_n < 0 {
+                    return Err(crate::Error::DataInvalid {
+                        message: "negative row_ranges count".into(),
+                        source: None,
+                    });
+                }
+                // Bound the eager reservation by the remaining bytes: each 
RowRange is
+                // 16 bytes (two i64), so a crafted huge count cannot 
over-reserve.
+                let mut ranges = Vec::with_capacity((ranges_n as 
usize).min(cur.len() / 16));
+                for _ in 0..ranges_n {
+                    let from = read_i64(&mut cur)?;
+                    let to = read_i64(&mut cur)?;
+                    if from > to {
+                        return Err(crate::Error::DataInvalid {
+                            message: format!("row range from {from} > to 
{to}"),
+                            source: None,
+                        });
+                    }
+                    ranges.push(RowRange::new(from, to));
+                }
+                let scores_flag = read_u8(&mut cur)?;
+                if scores_flag != 0 {
+                    return Err(crate::Error::Unsupported {
+                        message: "IndexedSplit scores are not 
supported".into(),
+                    });
+                }
+                let mut body = body;
+                body.row_ranges = Some(ranges);
+                body
+            }
+            other => {
+                return Err(crate::Error::Unsupported {
+                    message: format!("unsupported split type id: {other}"),
+                });
+            }
+        };
+        if !cur.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: format!("{} trailing bytes after SPLIT_V1 frame", 
cur.len()),
+                source: None,
+            });
+        }
+        Ok(split)
+    }
 }
 
 /// Java `DataSplit#MAGIC` / `VERSION` for the serialize format.
@@ -775,6 +977,116 @@ fn write_deletion_list(
     Ok(())
 }
 
+/// 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]> {
+    if cur.len() < n {
+        return Err(crate::Error::DataInvalid {
+            message: format!("split buffer underrun: need {n}, have {}", 
cur.len()),
+            source: None,
+        });
+    }
+    let (head, tail) = cur.split_at(n);
+    *cur = tail;
+    Ok(head)
+}
+
+fn read_u8(cur: &mut &[u8]) -> crate::Result<u8> {
+    Ok(take(cur, 1)?[0])
+}
+
+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> {
+    Ok(i32::from_be_bytes(take(cur, 4)?.try_into().unwrap()))
+}
+
+fn read_i64(cur: &mut &[u8]) -> crate::Result<i64> {
+    Ok(i64::from_be_bytes(take(cur, 8)?.try_into().unwrap()))
+}
+
+fn utf_err() -> crate::Error {
+    crate::Error::DataInvalid {
+        message: "invalid modified UTF-8 in split".into(),
+        source: None,
+    }
+}
+
+/// 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> {
+    let len = read_i16(cur)? as u16 as usize;
+    let bytes = take(cur, len)?;
+    let mut units: Vec<u16> = Vec::new();
+    let mut i = 0;
+    while i < bytes.len() {
+        let b = bytes[i];
+        let (unit, adv) = if b & 0x80 == 0 {
+            // 1-byte: 0xxxxxxx (includes raw 0x00)
+            (b as u16, 1)
+        } else if b & 0xE0 == 0xC0 {
+            // 2-byte: 110xxxxx 10xxxxxx (0xC0 0x80 is Java's encoding of '\0')
+            if i + 1 >= bytes.len() || bytes[i + 1] & 0xC0 != 0x80 {
+                return Err(utf_err());
+            }
+            ((((b & 0x1F) as u16) << 6) | (bytes[i + 1] & 0x3F) as u16, 2)
+        } else if b & 0xF0 == 0xE0 {
+            // 3-byte: 1110xxxx 10xxxxxx 10xxxxxx
+            if i + 2 >= bytes.len() || bytes[i + 1] & 0xC0 != 0x80 || bytes[i 
+ 2] & 0xC0 != 0x80 {
+                return Err(utf_err());
+            }
+            (
+                (((b & 0x0F) as u16) << 12)
+                    | (((bytes[i + 1] & 0x3F) as u16) << 6)
+                    | (bytes[i + 2] & 0x3F) as u16,
+                3,
+            )
+        } else {
+            return Err(utf_err());
+        };
+        units.push(unit);
+        i += adv;
+    }
+    String::from_utf16(&units).map_err(|_| utf_err())
+}
+
+/// Reverse of [`write_deletion_list`]: `0` = null list; else `1` + count + 
per entry
+/// (`0` = null, or `1` + path + offset + length + cardinality, where `-1` 
decodes to `None`).
+fn read_deletion_list(cur: &mut &[u8]) -> 
crate::Result<Option<Vec<Option<DeletionFile>>>> {
+    if read_u8(cur)? == 0 {
+        return Ok(None);
+    }
+    let count = read_i32(cur)?;
+    if count < 0 {
+        return Err(crate::Error::DataInvalid {
+            message: format!("deletion list count {count} < 0"),
+            source: None,
+        });
+    }
+    // Bound the eager reservation by the remaining bytes: each entry is at 
least a
+    // 1-byte null flag, so a crafted huge count cannot over-reserve.
+    let mut out = Vec::with_capacity((count as usize).min(cur.len()));
+    for _ in 0..count {
+        if read_u8(cur)? == 0 {
+            out.push(None);
+        } else {
+            let path = read_java_utf(cur)?;
+            let offset = read_i64(cur)?;
+            let length = read_i64(cur)?;
+            let cardinality = read_i64(cur)?;
+            let cardinality = if cardinality == -1 {
+                None
+            } else {
+                Some(cardinality)
+            };
+            out.push(Some(DeletionFile::new(path, offset, length, 
cardinality)));
+        }
+    }
+    Ok(Some(out))
+}
+
 /// Builder for [DataSplit].
 ///
 /// Reference: 
[DataSplit.Builder](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java)
@@ -1192,9 +1504,16 @@ mod tests {
 
     #[test]
     fn serialize_matches_datasplit_v8() {
-        use chrono::DateTime;
         // Golden generated by Java (paimon-core DataSplitCompatibleTest) for 
cross-language parity.
         let expected = include_bytes!("goldens/datasplit_v8.bin");
+        let split = sample_v8_split();
+        assert_eq!(split.serialize().unwrap().as_slice(), &expected[..]);
+    }
+
+    /// The fixture split whose Java-generated bytes live in 
`goldens/datasplit_v8.bin`.
+    /// Shared by the serialize and deserialize golden tests.
+    fn sample_v8_split() -> DataSplit {
+        use chrono::DateTime;
 
         let mut pb = crate::spec::BinaryRowBuilder::new(1);
         pb.write_bytes(0, b"aaaaa");
@@ -1235,7 +1554,7 @@ mod tests {
             ),
         };
 
-        let split = DataSplitBuilder::new()
+        DataSplitBuilder::new()
             .with_snapshot(18)
             .with_partition(pb.build())
             .with_bucket(20)
@@ -1250,9 +1569,145 @@ mod tests {
             ))])
             .with_raw_convertible(false)
             .build()
+            .unwrap()
+    }
+
+    #[test]
+    fn deserialize_matches_datasplit_v8_golden() {
+        let golden = include_bytes!("goldens/datasplit_v8.bin");
+        let split = DataSplit::deserialize(golden).unwrap();
+        assert_eq!(split, sample_v8_split());
+    }
+
+    #[test]
+    fn deserialize_round_trips_serialize() {
+        let split = sample_v8_split();
+        assert_eq!(
+            DataSplit::deserialize(&split.serialize().unwrap()).unwrap(),
+            split
+        );
+    }
+
+    #[test]
+    fn deserialize_rejects_trailing_bytes() {
+        let mut bytes = sample_v8_split().serialize().unwrap();
+        bytes.push(0xFF);
+        assert!(DataSplit::deserialize(&bytes).is_err());
+    }
+
+    #[test]
+    fn deserialize_rejects_bad_magic() {
+        let bytes = [0u8; 12];
+        assert!(DataSplit::deserialize(&bytes).is_err());
+    }
+
+    // A non-null `beforeDeletionFiles` list is rejected rather than silently
+    // discarded (Java treats such a split as invalid). Walk the wire layout 
with
+    // the same readers to locate the null-list flag, then flip it to a 
non-null
+    // (empty) list and confirm deserialize fails loudly.
+    #[test]
+    fn deserialize_rejects_non_null_before_deletion_files() {
+        let split = sample_v8_split();
+        let bytes = split.serialize().unwrap();
+        assert!(DataSplit::deserialize(&bytes).is_ok());
+
+        let mut cur = bytes.as_slice();
+        read_i64(&mut cur).unwrap(); // magic
+        read_i32(&mut cur).unwrap(); // version
+        read_i64(&mut cur).unwrap(); // snapshot_id
+        let part_len = read_i32(&mut cur).unwrap() as usize;
+        take(&mut cur, part_len).unwrap(); // partition bytes
+        read_i32(&mut cur).unwrap(); // bucket
+        read_java_utf(&mut cur).unwrap(); // bucket_path
+        if read_u8(&mut cur).unwrap() != 0 {
+            read_i32(&mut cur).unwrap(); // total_buckets
+        }
+        read_i32(&mut cur).unwrap(); // beforeFiles count (0)
+                                     // `cur` now points at the 
beforeDeletionFiles null-list flag byte.
+        let offset = bytes.len() - cur.len();
+
+        let mut patched = bytes.clone();
+        // null-list marker (0x00) -> non-null empty list (0x01 + i32 count 0).
+        patched.splice(offset..offset + 1, [1u8, 0, 0, 0, 0]);
+        match DataSplit::deserialize(&patched) {
+            Err(crate::Error::Unsupported { .. }) => {}
+            other => panic!("expected Unsupported, got {other:?}"),
+        }
+    }
+
+    // A streaming split (isStreaming = true) is rejected rather than silently
+    // dropping the bit: Rust only writes/serves batch splits (isStreaming =
+    // false), and Java readers branch on this flag. The isStreaming byte is 
the
+    // second-to-last byte on the wire (isStreaming, then raw_convertible).
+    #[test]
+    fn deserialize_rejects_streaming_split() {
+        let bytes = sample_v8_split().serialize().unwrap();
+        assert!(DataSplit::deserialize(&bytes).is_ok());
+
+        let mut patched = bytes.clone();
+        let pos = patched.len() - 2; // isStreaming flag
+        assert_eq!(
+            patched[pos], 0,
+            "fixture must serialize isStreaming = false"
+        );
+        patched[pos] = 1;
+        match DataSplit::deserialize(&patched) {
+            Err(crate::Error::Unsupported { .. }) => {}
+            other => panic!("expected Unsupported, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn deserialize_rejects_huge_data_files_count_without_aborting() {
+        // Build a well-formed split with an empty data-file list so the 
trailing layout
+        // after `data_files_n` is fixed: deletion-null (1) + isStreaming (1) +
+        // raw_convertible (1) = 3 bytes. Thus `data_files_n` (i32) sits at 
`len - 7`.
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(crate::spec::BinaryRowBuilder::new(0).build())
+            .with_bucket(0)
+            .with_bucket_path("p".to_string())
+            .with_total_buckets(1)
+            .with_data_files(vec![])
+            .with_raw_convertible(false)
+            .build()
             .unwrap();
+        let mut bytes = split.serialize().unwrap();
+        // Sanity: unpatched buffer round-trips.
+        assert!(DataSplit::deserialize(&bytes).is_ok());
+
+        let pos = bytes.len() - 7;
+        bytes[pos..pos + 4].copy_from_slice(&i32::MAX.to_be_bytes());
+        match DataSplit::deserialize(&bytes) {
+            Err(crate::Error::DataInvalid { .. }) => {}
+            other => panic!("expected DataInvalid, got {other:?}"),
+        }
+    }
 
-        assert_eq!(split.serialize().unwrap().as_slice(), &expected[..]);
+    // Same hardening for the IndexedSplit row-ranges count in the SPLIT_V1 
frame.
+    #[test]
+    fn deserialize_split_v1_rejects_huge_ranges_count_without_aborting() {
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(crate::spec::BinaryRowBuilder::new(0).build())
+            .with_bucket(0)
+            .with_bucket_path("p".to_string())
+            .with_total_buckets(1)
+            .with_data_files(vec![])
+            .with_row_ranges(vec![RowRange::new(0, 5)])
+            .with_raw_convertible(false)
+            .build()
+            .unwrap();
+        let mut bytes = split.serialize_split_v1().unwrap();
+        assert!(DataSplit::deserialize_split_v1(&bytes).is_ok());
+
+        // Trailing after `ranges_n` (i32): one RowRange (16) + scores flag 
(1) = 17 bytes.
+        let pos = bytes.len() - 21;
+        bytes[pos..pos + 4].copy_from_slice(&i32::MAX.to_be_bytes());
+        match DataSplit::deserialize_split_v1(&bytes) {
+            Err(crate::Error::DataInvalid { .. }) => {}
+            other => panic!("expected DataInvalid, got {other:?}"),
+        }
     }
 
     #[test]
@@ -1280,6 +1735,74 @@ mod tests {
         assert!(write_java_utf(&mut sink, &"a".repeat(70000)).is_err());
     }
 
+    #[test]
+    fn java_utf_round_trips() {
+        for s in [
+            "",
+            "ascii",
+            "caf\u{e9}",
+            "\0",
+            "\u{800}",
+            "\u{1F600}emoji",
+            &"x".repeat(1000),
+        ] {
+            let mut out = Vec::new();
+            write_java_utf(&mut out, s).unwrap();
+            let mut cur = out.as_slice();
+            assert_eq!(read_java_utf(&mut cur).unwrap(), s);
+            assert!(cur.is_empty(), "should consume exactly the encoded 
bytes");
+        }
+    }
+
+    #[test]
+    fn java_utf_rejects_truncated() {
+        let mut out = Vec::new();
+        write_java_utf(&mut out, "hello").unwrap();
+        out.truncate(out.len() - 2); // drop tail bytes
+        let mut cur = out.as_slice();
+        assert!(read_java_utf(&mut cur).is_err());
+    }
+
+    #[test]
+    fn java_utf_rejects_malformed_continuation() {
+        // len=2, lead 0xC0 (2-byte form) followed by 0x41 ('A'), which is NOT 
a
+        // 10xxxxxx continuation byte. Must error, not silently decode to 
"\u{1}".
+        let bytes = [0x00u8, 0x02, 0xC0, 0x41];
+        let mut cur = bytes.as_slice();
+        assert!(read_java_utf(&mut cur).is_err());
+    }
+
+    #[test]
+    fn deletion_list_round_trips() {
+        for list in [
+            None,
+            Some(vec![None]),
+            Some(vec![
+                Some(DeletionFile::new("f.idx".into(), 1, 2, Some(3))),
+                None,
+            ]),
+        ] {
+            let mut out = Vec::new();
+            write_deletion_list(&mut out, list.as_deref()).unwrap();
+            let mut cur = out.as_slice();
+            assert_eq!(read_deletion_list(&mut cur).unwrap(), list);
+            assert!(cur.is_empty());
+        }
+    }
+
+    #[test]
+    fn deletion_list_cardinality_sentinel_maps_to_none() {
+        // A -1 cardinality on the wire must decode back to `None`, matching 
the
+        // forward `unwrap_or(-1)`.
+        let list = Some(vec![Some(DeletionFile::new("f.idx".into(), 4, 5, 
None))]);
+        let mut out = Vec::new();
+        write_deletion_list(&mut out, list.as_deref()).unwrap();
+        let mut cur = out.as_slice();
+        let back = read_deletion_list(&mut cur).unwrap().unwrap();
+        assert_eq!(back[0].as_ref().unwrap().cardinality(), None);
+        assert!(cur.is_empty());
+    }
+
     #[test]
     fn binary_array_str_var_element() {
         // Element > 7 bytes takes the offset+length pointer branch, which 
datasplit-v8
@@ -1395,4 +1918,105 @@ mod tests {
             .unwrap();
         assert_eq!(split.serialize_split_v1().unwrap(), expected);
     }
+
+    // The scores-stripped IndexedSplit frame the Rust serializer emits (see
+    // `serialize_indexed_split_v1_matches_golden`): the Java golden's 
trailing vector scores are
+    // replaced by a `false` scores flag, which is the exact byte form 
`deserialize_split_v1` supports.
+    fn indexed_split_v1_no_scores() -> Vec<u8> {
+        let golden = include_bytes!("goldens/split_v1_indexed.bin");
+        let scores_len = 1 + 4 + 3 * 4; // writeBoolean(true) + count + 3 
floats
+        let mut bytes = golden[..golden.len() - scores_len].to_vec();
+        bytes.push(0); // scores = false
+        bytes
+    }
+
+    #[test]
+    fn deserialize_split_v1_data_golden() {
+        // `split_v1_data.bin` is the SPLIT_V1 (type 1) frame produced by 
`v1_data_split_builder`.
+        let golden = include_bytes!("goldens/split_v1_data.bin");
+        assert_eq!(
+            DataSplit::deserialize_split_v1(golden).unwrap(),
+            v1_data_split_builder().build().unwrap()
+        );
+        // Round-trip gate: bytes -> split -> bytes.
+        assert_eq!(
+            DataSplit::deserialize_split_v1(golden)
+                .unwrap()
+                .serialize_split_v1()
+                .unwrap()
+                .as_slice(),
+            &golden[..]
+        );
+    }
+
+    #[test]
+    fn deserialize_split_v1_indexed_golden() {
+        // IndexedSplit (type 3) without vector scores, carrying row_ranges.
+        let frame = indexed_split_v1_no_scores();
+        let split = DataSplit::deserialize_split_v1(&frame).unwrap();
+        assert_eq!(
+            split.row_ranges(),
+            Some([RowRange::new(1, 4), RowRange::new(11, 13)].as_slice())
+        );
+        // Round-trips back to the same bytes.
+        assert_eq!(split.serialize_split_v1().unwrap(), frame);
+    }
+
+    #[test]
+    fn deserialize_split_v1_rejects_indexed_scores() {
+        // The raw Java golden carries vector scores, which the Rust split 
cannot model.
+        let golden = include_bytes!("goldens/split_v1_indexed.bin");
+        assert!(matches!(
+            DataSplit::deserialize_split_v1(golden),
+            Err(crate::Error::Unsupported { .. })
+        ));
+    }
+
+    #[test]
+    fn deserialize_split_v1_rejects_unsupported_type() {
+        // hand-build a frame with type id 2
+        let mut b = Vec::new();
+        b.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
+        b.extend_from_slice(&SPLIT_SER_VERSION.to_be_bytes());
+        b.extend_from_slice(&2i32.to_be_bytes());
+        assert!(matches!(
+            DataSplit::deserialize_split_v1(&b),
+            Err(crate::Error::Unsupported { .. })
+        ));
+    }
+
+    #[test]
+    fn deserialize_split_v1_rejects_trailing_bytes() {
+        let golden = include_bytes!("goldens/split_v1_data.bin");
+        let mut b = golden.to_vec();
+        b.push(0xff); // one trailing byte after a complete frame
+        assert!(matches!(
+            DataSplit::deserialize_split_v1(&b),
+            Err(crate::Error::DataInvalid { .. })
+        ));
+    }
+
+    #[test]
+    fn deserialize_split_v1_rejects_inverted_row_range() {
+        // Take a valid IndexedSplit frame (first range [1,4]) and flip its 
bounds to from=5,to=2.
+        // `RowRange::new` asserts from <= to, so the loop must reject this 
before constructing it.
+        let mut frame = indexed_split_v1_no_scores();
+        let needle: Vec<u8> = 1i64
+            .to_be_bytes()
+            .iter()
+            .chain(4i64.to_be_bytes().iter())
+            .copied()
+            .collect();
+        let pos = frame
+            .windows(needle.len())
+            .position(|w| w == needle.as_slice())
+            .expect("range [1,4] present in indexed frame");
+        let mut replacement = 5i64.to_be_bytes().to_vec();
+        replacement.extend_from_slice(&2i64.to_be_bytes());
+        frame[pos..pos + needle.len()].copy_from_slice(&replacement);
+        assert!(matches!(
+            DataSplit::deserialize_split_v1(&frame),
+            Err(crate::Error::DataInvalid { .. })
+        ));
+    }
 }

Reply via email to