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 8118b6d6 fix(file_index): align file index format with Java V1 (#719)
8118b6d6 is described below
commit 8118b6d6210b3db18eebddde1aaa128f4eb0fe62
Author: QuakeWang <[email protected]>
AuthorDate: Sun Aug 16 22:46:00 2026 +0800
fix(file_index): align file index format with Java V1 (#719)
---
crates/paimon/src/file_index/file_index_format.rs | 701 ++++++++++++++++------
1 file changed, 524 insertions(+), 177 deletions(-)
diff --git a/crates/paimon/src/file_index/file_index_format.rs
b/crates/paimon/src/file_index/file_index_format.rs
index 0122c7b4..6bd0f4ca 100644
--- a/crates/paimon/src/file_index/file_index_format.rs
+++ b/crates/paimon/src/file_index/file_index_format.rs
@@ -17,33 +17,159 @@
use std::collections::HashMap;
-use bytes::{Buf, BufMut, Bytes, BytesMut};
+use bytes::{BufMut, Bytes, BytesMut};
use crate::{
io::{FileIO, FileRead, FileStatus, InputFile, OutputFile},
Error,
};
-/// Default 1MB read block size
-const READ_BLOCK_SIZE: u64 = 1024 * 1024;
+const FIXED_HEADER_LENGTH: usize = 16;
+const MIN_HEADER_LENGTH: usize = 24;
-/// Quoted from the Java implement of the structure,
-/// `MAGIC`` is used to mark the beginning of a FileFormat structure.
+/// Marks the beginning of a file index file.
pub const MAGIC: u64 = 1493475289347502;
-/// Used to mark an empty INDEX.
-pub const EMPTY_INDEX_FLAG: i64 = -1;
+/// Marks an empty index payload.
+pub const EMPTY_INDEX_FLAG: i32 = -1;
#[derive(Debug)]
struct IndexInfo {
- start_pos: i64,
- length: i64,
+ start_pos: i32,
+ length: i32,
}
#[repr(i32)]
#[derive(Debug, PartialEq, Eq)]
enum Version {
- V1,
+ V1 = 1,
+}
+
+fn format_invalid(message: impl Into<String>) -> Error {
+ Error::FileIndexFormatInvalid {
+ message: message.into(),
+ }
+}
+
+fn modified_utf8_len(value: &str) -> crate::Result<usize> {
+ let mut len = 0usize;
+ for unit in value.encode_utf16() {
+ let encoded_len = if (0x0001..=0x007f).contains(&unit) {
+ 1
+ } else if unit > 0x07ff {
+ 3
+ } else {
+ 2
+ };
+ len = len
+ .checked_add(encoded_len)
+ .ok_or_else(|| format_invalid("modified UTF-8 length overflow"))?;
+ if len > u16::MAX as usize {
+ return Err(format_invalid(format!(
+ "modified UTF-8 string is {len} bytes, exceeding the
65535-byte limit"
+ )));
+ }
+ }
+ Ok(len)
+}
+
+fn write_java_utf(buffer: &mut BytesMut, value: &str) -> crate::Result<()> {
+ let encoded_len = modified_utf8_len(value)?;
+ buffer.put_u16(encoded_len as u16);
+ for unit in value.encode_utf16() {
+ if (0x0001..=0x007f).contains(&unit) {
+ buffer.put_u8(unit as u8);
+ } else if unit > 0x07ff {
+ buffer.put_u8(0xe0 | (unit >> 12) as u8);
+ buffer.put_u8(0x80 | ((unit >> 6) & 0x3f) as u8);
+ buffer.put_u8(0x80 | (unit & 0x3f) as u8);
+ } else {
+ buffer.put_u8(0xc0 | (unit >> 6) as u8);
+ buffer.put_u8(0x80 | (unit & 0x3f) as u8);
+ }
+ }
+ Ok(())
+}
+
+fn take_bytes<'a>(buffer: &mut &'a [u8], len: usize, field: &str) ->
crate::Result<&'a [u8]> {
+ if buffer.len() < len {
+ return Err(format_invalid(format!(
+ "truncated {field}: need {len} bytes, but only {} remain",
+ buffer.len()
+ )));
+ }
+ let (value, remaining) = buffer.split_at(len);
+ *buffer = remaining;
+ Ok(value)
+}
+
+fn read_u16(buffer: &mut &[u8], field: &str) -> crate::Result<u16> {
+ Ok(u16::from_be_bytes(
+ take_bytes(buffer, 2, field)?.try_into().unwrap(),
+ ))
+}
+
+fn read_i32(buffer: &mut &[u8], field: &str) -> crate::Result<i32> {
+ Ok(i32::from_be_bytes(
+ take_bytes(buffer, 4, field)?.try_into().unwrap(),
+ ))
+}
+
+fn read_u64(buffer: &mut &[u8], field: &str) -> crate::Result<u64> {
+ Ok(u64::from_be_bytes(
+ take_bytes(buffer, 8, field)?.try_into().unwrap(),
+ ))
+}
+
+fn read_count(buffer: &mut &[u8], field: &str) -> crate::Result<usize> {
+ let count = read_i32(buffer, field)?;
+ usize::try_from(count).map_err(|_| format_invalid(format!("negative
{field}: {count}")))
+}
+
+fn read_java_utf(buffer: &mut &[u8], field: &str) -> crate::Result<String> {
+ let len = read_u16(buffer, &format!("{field} length"))? as usize;
+ let bytes = take_bytes(buffer, len, field)?;
+ let mut units = Vec::with_capacity(bytes.len());
+ let mut offset = 0;
+
+ while offset < bytes.len() {
+ let first = bytes[offset];
+ let (unit, width) = if first & 0x80 == 0 {
+ (first as u16, 1)
+ } else if first & 0xe0 == 0xc0 {
+ if offset + 1 >= bytes.len() || bytes[offset + 1] & 0xc0 != 0x80 {
+ return Err(format_invalid(format!("invalid modified UTF-8 in
{field}")));
+ }
+ (
+ (((first & 0x1f) as u16) << 6) | (bytes[offset + 1] & 0x3f) as
u16,
+ 2,
+ )
+ } else if first & 0xf0 == 0xe0 {
+ if offset + 2 >= bytes.len()
+ || bytes[offset + 1] & 0xc0 != 0x80
+ || bytes[offset + 2] & 0xc0 != 0x80
+ {
+ return Err(format_invalid(format!("invalid modified UTF-8 in
{field}")));
+ }
+ (
+ (((first & 0x0f) as u16) << 12)
+ | (((bytes[offset + 1] & 0x3f) as u16) << 6)
+ | (bytes[offset + 2] & 0x3f) as u16,
+ 3,
+ )
+ } else {
+ return Err(format_invalid(format!("invalid modified UTF-8 in
{field}")));
+ };
+ units.push(unit);
+ offset += width;
+ }
+
+ String::from_utf16(&units)
+ .map_err(|_| format_invalid(format!("invalid UTF-16 sequence in
{field}")))
+}
+
+fn usize_to_i32(value: usize, field: &str) -> crate::Result<i32> {
+ i32::try_from(value).map_err(|_| format_invalid(format!("{field} exceeds
i32::MAX: {value}")))
}
/// File index file format. All columns and offsets are stored in the header.
@@ -86,9 +212,9 @@ enum Version {
/// - `version`: 4-byte integer
/// - `head length`: 4-byte integer
/// - `column number`: 4-byte integer
-/// - `column x`: variable-length UTF-8 string (length + bytes)
+/// - `column x`: Java modified UTF-8 string (2-byte length + bytes)
/// - `index number`: 4-byte integer (number of index items below)
-/// - `index name x`: variable-length UTF-8 string
+/// - `index name x`: Java modified UTF-8 string
/// - `start pos`: 4-byte integer
/// - `length`: 4-byte integer
/// - `redundant length`: 4-byte integer (for compatibility with future
versions; content is zero in this version)
@@ -96,34 +222,41 @@ enum Version {
/// - `BODY`: sequence of index data (concatenated index data for each column)
/// ```
///
-/// Impl Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java>
+/// `None` represents an empty index and is encoded with [`EMPTY_INDEX_FLAG`].
`Some(Bytes::new())`
+/// represents a present index with a zero-length payload.
+///
+/// Implementation reference:
<https://github.com/apache/paimon/blob/release-2.0/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java>
pub async fn write_column_indexes(
path: &str,
- indexes: HashMap<String, HashMap<String, Bytes>>,
+ indexes: HashMap<String, HashMap<String, Option<Bytes>>>,
) -> crate::Result<OutputFile> {
let file_io = FileIO::from_path(path)?.build()?;
let output = file_io.new_output(path)?;
- let mut writer = output.writer().await?;
-
let mut body_info: HashMap<String, HashMap<String, IndexInfo>> =
HashMap::new();
- let mut total_data_size = 0;
+ let mut total_data_size = 0usize;
- // Calculate the total data size
for bytes_map in indexes.values() {
- for data in bytes_map.values() {
+ for data in bytes_map.values().flatten() {
if !data.is_empty() {
- total_data_size += data.len();
+ total_data_size = total_data_size
+ .checked_add(data.len())
+ .ok_or_else(|| format_invalid("file index body length
overflow"))?;
}
}
}
+ usize_to_i32(total_data_size, "file index body length")?;
let mut body = BytesMut::with_capacity(total_data_size);
- for (column_name, bytes_map) in indexes.into_iter() {
- let inner_map = body_info.entry(column_name.clone()).or_default();
+ for (column_name, bytes_map) in indexes {
+ let inner_map = body_info.entry(column_name).or_default();
for (index_name, data) in bytes_map {
- let start_position = body.len() as i64;
- if data.is_empty() {
+ if let Some(data) = data {
+ let start_pos = usize_to_i32(body.len(), "file index body
offset")?;
+ let length = usize_to_i32(data.len(), "file index entry
length")?;
+ body.extend_from_slice(&data);
+ inner_map.insert(index_name, IndexInfo { start_pos, length });
+ } else {
inner_map.insert(
index_name,
IndexInfo {
@@ -131,56 +264,42 @@ pub async fn write_column_indexes(
length: 0,
},
);
- } else {
- body.extend_from_slice(&data);
- inner_map.insert(
- index_name,
- IndexInfo {
- start_pos: start_position,
- length: body.len() as i64 - start_position,
- },
- );
}
}
}
- // write_head(writer, &body_info).await?;
let head_length = calculate_head_length(&body_info)?;
+ let head_length_i32 = usize_to_i32(head_length, "file index header
length")?;
let mut head_buffer = BytesMut::with_capacity(head_length);
- // Magic
- head_buffer.put_u64_le(MAGIC);
- // Version
- head_buffer.put_i32_le(Version::V1 as i32);
- // HeadLength
- head_buffer.put_i32_le(head_length as i32);
- // ColumnSize
- head_buffer.put_i32_le(body_info.len() as i32);
+ head_buffer.put_u64(MAGIC);
+ head_buffer.put_i32(Version::V1 as i32);
+ head_buffer.put_i32(head_length_i32);
+ head_buffer.put_i32(usize_to_i32(body_info.len(), "column count")?);
for (column_name, index_info) in body_info {
- // ColumnName
- head_buffer.put_u16_le(column_name.len() as u16);
- head_buffer.put_slice(column_name.as_bytes());
- // IndexTypeSize
- head_buffer.put_i32_le(index_info.len() as i32);
- // ColumnInfo,offset = headLength
+ write_java_utf(&mut head_buffer, &column_name)?;
+ head_buffer.put_i32(usize_to_i32(index_info.len(), "index count")?);
for (index_name, IndexInfo { start_pos, length }) in index_info {
- head_buffer.put_u16_le(index_name.len() as u16);
- head_buffer.put_slice(index_name.as_bytes());
+ write_java_utf(&mut head_buffer, &index_name)?;
let adjusted_start = if start_pos == EMPTY_INDEX_FLAG {
EMPTY_INDEX_FLAG
} else {
- start_pos + head_length as i64
+ start_pos.checked_add(head_length_i32).ok_or_else(|| {
+ format_invalid(format!(
+ "file index offset exceeds i32::MAX: {start_pos} +
{head_length_i32}"
+ ))
+ })?
};
- head_buffer.put_i64_le(adjusted_start);
- head_buffer.put_i64_le(length);
+ head_buffer.put_i32(adjusted_start);
+ head_buffer.put_i32(length);
}
}
- // Redundant length for future compatibility
- head_buffer.put_i32_le(0);
+ head_buffer.put_i32(0);
+ debug_assert_eq!(head_buffer.len(), head_length);
- // Write into
+ let mut writer = output.writer().await?;
writer.write(head_buffer.freeze()).await?;
writer.write(body.freeze()).await?;
writer.close().await?;
@@ -190,24 +309,23 @@ pub async fn write_column_indexes(
fn calculate_head_length(
body_info: &HashMap<String, HashMap<String, IndexInfo>>,
) -> crate::Result<usize> {
- // Magic + Version + HeadLength + ColumnNumber + RedundantLength
- let base_length = 8 + 4 + 4 + 4 + 4;
- let mut total_length = base_length;
+ let mut total_length = MIN_HEADER_LENGTH;
for (column_name, index_info) in body_info {
- // Column name length + actual column name length
- total_length += 2 + column_name.len();
- // IndexTypeSize (index number)
- total_length += 4;
+ total_length = total_length
+ .checked_add(2 + modified_utf8_len(column_name)?)
+ .and_then(|length| length.checked_add(4))
+ .ok_or_else(|| format_invalid("file index header length
overflow"))?;
for index_name in index_info.keys() {
- // Index name length + actual index name length
- total_length += 2 + index_name.len();
- // start_pos (8 bytes) + length (8 bytes)
- total_length += 16;
+ total_length = total_length
+ .checked_add(2 + modified_utf8_len(index_name)?)
+ .and_then(|length| length.checked_add(8))
+ .ok_or_else(|| format_invalid("file index header length
overflow"))?;
}
}
+ usize_to_i32(total_length, "file index header length")?;
Ok(total_length)
}
@@ -220,7 +338,7 @@ impl FileIndex {
pub async fn get_column_index(
&self,
column_name: &str,
- ) -> crate::Result<HashMap<String, Bytes>> {
+ ) -> crate::Result<HashMap<String, Option<Bytes>>> {
if let Some(index_info) = self.header.get(column_name) {
let mut result = HashMap::new();
for (index_name, info) in index_info {
@@ -235,7 +353,9 @@ impl FileIndex {
}
}
- pub async fn get_index(&self) -> crate::Result<HashMap<String,
HashMap<String, Bytes>>> {
+ pub async fn get_index(
+ &self,
+ ) -> crate::Result<HashMap<String, HashMap<String, Option<Bytes>>>> {
let mut result = HashMap::new();
for (column_name, index_info) in self.header.iter() {
let mut column_index = HashMap::new();
@@ -251,17 +371,27 @@ impl FileIndex {
async fn get_bytes_with_start_and_length(
&self,
index_info: &IndexInfo,
- ) -> crate::Result<Bytes> {
- self.reader
- .read(index_info.start_pos as u64..(index_info.start_pos +
index_info.length) as u64)
- .await
+ ) -> crate::Result<Option<Bytes>> {
+ if index_info.start_pos == EMPTY_INDEX_FLAG {
+ return Ok(None);
+ }
+ let start = index_info.start_pos as u64;
+ let end = start
+ .checked_add(index_info.length as u64)
+ .ok_or_else(|| format_invalid("file index range overflow"))?;
+ self.reader.read(start..end).await.map(Some)
}
/// Read bytes from the index file at the specified position and length
pub async fn read_bytes(&self, start: i64, length: i64) ->
crate::Result<Bytes> {
- self.reader
- .read(start as u64..(start + length) as u64)
- .await
+ let start = u64::try_from(start)
+ .map_err(|_| format_invalid(format!("negative read offset:
{start}")))?;
+ let length = u64::try_from(length)
+ .map_err(|_| format_invalid(format!("negative read length:
{length}")))?;
+ let end = start
+ .checked_add(length)
+ .ok_or_else(|| format_invalid("file index read range overflow"))?;
+ self.reader.read(start..end).await
}
}
@@ -285,117 +415,142 @@ impl FileIndexFormatReader {
}
async fn read_header(&mut self) -> crate::Result<HashMap<String,
HashMap<String, IndexInfo>>> {
- let read_size = if self.stat.size < READ_BLOCK_SIZE {
- self.stat.size
- } else {
- READ_BLOCK_SIZE
- };
- let mut buffer = self.reader.read(0..read_size).await?;
+ if self.stat.size < FIXED_HEADER_LENGTH as u64 {
+ return Err(format_invalid(format!(
+ "truncated fixed header: need {FIXED_HEADER_LENGTH} bytes, but
file has {}",
+ self.stat.size
+ )));
+ }
- // Magic (8 bytes)
- let magic = buffer.get_u64_le();
+ let fixed_header = self
+ .read_exact_range(0, FIXED_HEADER_LENGTH as u64, "fixed header")
+ .await?;
+ let mut fixed = fixed_header.as_ref();
+
+ let magic = read_u64(&mut fixed, "magic")?;
if magic != MAGIC {
- return Err(Error::FileIndexFormatInvalid {
- message: format!("Expected MAGIC: {MAGIC}, but found:
{magic}"),
- });
+ return Err(format_invalid(format!(
+ "expected magic {MAGIC}, but found {magic}"
+ )));
}
- // Version (4 bytes)
- let version = buffer.get_i32_le();
+ let version = read_i32(&mut fixed, "version")?;
if version != Version::V1 as i32 {
- return Err(Error::FileIndexFormatInvalid {
- message: format!(
- "Unsupported file index version: expected {}, but found:
{}",
- Version::V1 as i32,
- version
- ),
- });
+ return Err(format_invalid(format!(
+ "unsupported file index version: expected {}, but found
{version}",
+ Version::V1 as i32
+ )));
}
- // Head Length (4 bytes)
- let head_length = buffer.get_i32_le() as usize;
-
- // Ensure the header is fully contained in the buffer
- if buffer.len() < head_length {
- let remaining = head_length - buffer.len();
- let mut remaining_head_buffer = BytesMut::with_capacity(remaining);
- let additional_data = self
- .reader
- .read(buffer.len() as u64..buffer.len() as u64 + remaining as
u64)
- .await?;
- remaining_head_buffer.extend_from_slice(&additional_data);
- buffer = Bytes::from(
- [buffer.slice(0..),
remaining_head_buffer.freeze().slice(0..)].concat(),
- );
+ let head_length = read_i32(&mut fixed, "header length")?;
+ let head_length = usize::try_from(head_length)
+ .map_err(|_| format_invalid(format!("negative header length:
{head_length}")))?;
+ if head_length < MIN_HEADER_LENGTH {
+ return Err(format_invalid(format!(
+ "header length {head_length} is smaller than the minimum
{MIN_HEADER_LENGTH}"
+ )));
+ }
+ if head_length as u64 > self.stat.size {
+ return Err(format_invalid(format!(
+ "header length {head_length} exceeds file size {}",
+ self.stat.size
+ )));
}
- // Column Number (4 bytes)
- let column_number = buffer.get_i32_le();
-
- let mut current_offset = 20;
+ let encoded_header = self
+ .read_exact_range(
+ FIXED_HEADER_LENGTH as u64,
+ head_length as u64,
+ "variable header",
+ )
+ .await?;
+ let mut buffer = encoded_header.as_ref();
+ let column_number = read_count(&mut buffer, "column count")?;
let mut header = HashMap::new();
for _ in 0..column_number {
- // Column Name Length (2 bytes)
- let column_name_len = buffer.get_u16_le();
- current_offset += 2;
-
- // Column Name (variable-length UTF-8 string)
- let column_name =
String::from_utf8(buffer.split_to(column_name_len as usize).to_vec())
- .map_err(|e| Error::FileIndexFormatInvalid {
- message: format!("Invalid UTF-8 sequence in column name:
{e}"),
- })?;
- current_offset += column_name_len as u64;
-
- // Index Number (4 bytes)
- let index_number = buffer.get_i32_le();
- current_offset += 4;
-
+ let column_name = read_java_utf(&mut buffer, "column name")?;
+ let index_number = read_count(&mut buffer, "index count")?;
let mut index_info_map = HashMap::new();
- for _ in 0..index_number {
- // Index Name Length (2 bytes)
- let index_name_len = buffer.get_u16_le();
- current_offset += 2;
-
- // Index Name (variable-length UTF-8 string)
- let index_name =
- String::from_utf8(buffer.split_to(index_name_len as
usize).to_vec()).unwrap();
- current_offset += index_name_len as u64;
-
- // Start Pos (8 bytes)
- let start_pos = buffer.get_i64_le();
- current_offset += 4;
-
- // Length (8 bytes)
- let length = buffer.get_i64_le();
- current_offset += 4;
+ for _ in 0..index_number {
+ let index_name = read_java_utf(&mut buffer, "index name")?;
+ let start_pos = read_i32(&mut buffer, "index start position")?;
+ let length = read_i32(&mut buffer, "index length")?;
+ Self::validate_index_range(start_pos, length, head_length as
u64, self.stat.size)?;
index_info_map.insert(index_name, IndexInfo { start_pos,
length });
}
header.insert(column_name, index_info_map);
}
- let redundant_length = buffer.get_i32_le() as u64;
- current_offset += 4;
-
- if redundant_length > 0 {
- let redundant_bytes = buffer.split_to(redundant_length as usize);
-
- if redundant_bytes.len() as u64 != redundant_length {
- return Err(Error::FileIndexFormatInvalid {
- message: format!(
- "Expected to read {} redundant bytes, but found only
{}, on offset {}",
- redundant_length,
- redundant_bytes.len(),
- current_offset
- ),
- });
- }
+ let redundant_length = read_count(&mut buffer, "redundant length")?;
+ take_bytes(&mut buffer, redundant_length, "redundant bytes")?;
+ if !buffer.is_empty() {
+ return Err(format_invalid(format!(
+ "{} trailing bytes remain in the file index header",
+ buffer.len()
+ )));
}
Ok(header)
}
+
+ async fn read_exact_range(&self, start: u64, end: u64, field: &str) ->
crate::Result<Bytes> {
+ let length = end
+ .checked_sub(start)
+ .ok_or_else(|| format_invalid(format!("invalid {field} range:
{start}..{end}")))?;
+ let expected = usize::try_from(length)
+ .map_err(|_| format_invalid(format!("{field} range is too
large")))?;
+ let bytes = self.reader.read(start..end).await?;
+ if bytes.len() != expected {
+ return Err(format_invalid(format!(
+ "truncated {field}: need {expected} bytes, but read {}",
+ bytes.len()
+ )));
+ }
+ Ok(bytes)
+ }
+
+ fn validate_index_range(
+ start_pos: i32,
+ length: i32,
+ head_length: u64,
+ file_size: u64,
+ ) -> crate::Result<()> {
+ if start_pos == EMPTY_INDEX_FLAG {
+ if length == 0 {
+ return Ok(());
+ }
+ return Err(format_invalid(format!(
+ "empty index has non-zero length {length}"
+ )));
+ }
+ if start_pos < 0 {
+ return Err(format_invalid(format!(
+ "negative index start position: {start_pos}"
+ )));
+ }
+ if length < 0 {
+ return Err(format_invalid(format!("negative index length:
{length}")));
+ }
+
+ let start = start_pos as u64;
+ if start < head_length {
+ return Err(format_invalid(format!(
+ "index start position {start} overlaps header ending at
{head_length}"
+ )));
+ }
+ let end = start
+ .checked_add(length as u64)
+ .ok_or_else(|| format_invalid("file index range overflow"))?;
+ if end > file_size {
+ return Err(format_invalid(format!(
+ "index range {start}..{end} exceeds file size {file_size}"
+ )));
+ }
+ Ok(())
+ }
}
#[cfg(test)]
@@ -405,13 +560,209 @@ mod file_index_format_tests {
use bytes::Bytes;
use std::collections::HashMap;
+ const JAVA_V1_SIMPLE: &str = concat!(
+ "00054e4ed01a35ae000000010000002a0000000100016100000001000162",
+ "0000002a0000000300000000010203"
+ );
+ const JAVA_V1_SEMANTIC: &str = concat!(
+ "00054e4ed01a35ae000000010000005200000001000963c080eda0bdedba80",
+ "000000030005656d707479ffffffff0000000000047a65726f000000520000",
+ "000000046461746100000052000000010000000041"
+ );
+ const JAVA_V1_UNICODE: &str = concat!(
+ "00054e4ed01a35ae000000010000003500000001000963c080eda0bdedba80",
+ "0000000100046461746100000035000000010000000041"
+ );
+
+ async fn write_fixture(path: &str, bytes: Vec<u8>) ->
crate::Result<InputFile> {
+ let file_io = FileIO::from_path(path)?.build()?;
+ let output = file_io.new_output(path)?;
+ output.write(Bytes::from(bytes)).await?;
+ Ok(output.to_input_file())
+ }
+
+ #[tokio::test]
+ async fn test_writer_matches_java_v1_bytes() -> crate::Result<()> {
+ let indexes = HashMap::from([(
+ "a".to_string(),
+ HashMap::from([("b".to_string(), Some(Bytes::from_static(&[1, 2,
3])))]),
+ )]);
+
+ let output = write_column_indexes("memory:/tmp/java_v1_writer",
indexes).await?;
+ let actual = output.to_input_file().read().await?;
+
+ assert_eq!(actual.as_ref(), hex::decode(JAVA_V1_SIMPLE).unwrap());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_writer_matches_java_modified_utf8_bytes() ->
crate::Result<()> {
+ let indexes = HashMap::from([(
+ "c\0🚀".to_string(),
+ HashMap::from([("data".to_string(),
Some(Bytes::from_static(&[65])))]),
+ )]);
+
+ let output =
write_column_indexes("memory:/tmp/java_v1_unicode_writer", indexes).await?;
+ let actual = output.to_input_file().read().await?;
+
+ assert_eq!(actual.as_ref(), hex::decode(JAVA_V1_UNICODE).unwrap());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_reader_reads_java_v1_bytes() -> crate::Result<()> {
+ let input = write_fixture(
+ "memory:/tmp/java_v1_reader",
+ hex::decode(JAVA_V1_SIMPLE).unwrap(),
+ )
+ .await?;
+
+ let reader = FileIndexFormatReader::get_file_index(input).await?;
+ let indexes = reader.get_column_index("a").await?;
+
+ assert_eq!(
+ indexes.get("b"),
+ Some(&Some(Bytes::from_static(&[1, 2, 3])))
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_reader_decodes_java_modified_utf8_and_empty_ranges() ->
crate::Result<()> {
+ let input = write_fixture(
+ "memory:/tmp/java_v1_semantic",
+ hex::decode(JAVA_V1_SEMANTIC).unwrap(),
+ )
+ .await?;
+
+ let reader = FileIndexFormatReader::get_file_index(input).await?;
+ let indexes = reader.get_column_index("c\0🚀").await?;
+
+ assert_eq!(indexes.get("empty"), Some(&None));
+ assert_eq!(indexes.get("zero"), Some(&Some(Bytes::new())));
+ assert_eq!(indexes.get("data"),
Some(&Some(Bytes::from_static(&[65]))));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_reader_rejects_every_truncated_prefix_without_panicking() ->
crate::Result<()> {
+ let bytes = hex::decode(JAVA_V1_SEMANTIC).unwrap();
+ for len in 0..bytes.len() {
+ let input = write_fixture(
+ &format!("memory:/tmp/truncated_file_index_{len}"),
+ bytes[..len].to_vec(),
+ )
+ .await?;
+
+ let error = match
FileIndexFormatReader::get_file_index(input).await {
+ Ok(_) => panic!("truncated prefix of {len} bytes must fail"),
+ Err(error) => error,
+ };
+ assert!(matches!(error, Error::FileIndexFormatInvalid { .. }));
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_reader_rejects_malformed_java_v1_headers() ->
crate::Result<()> {
+ let valid = hex::decode(JAVA_V1_SIMPLE).unwrap();
+ let mut cases = Vec::new();
+
+ let mut bytes = valid.clone();
+ bytes[12..16].copy_from_slice(&23i32.to_be_bytes());
+ cases.push(("short_header_length", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[12..16].copy_from_slice(&46i32.to_be_bytes());
+ cases.push(("header_exceeds_file", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[16..20].copy_from_slice(&(-1i32).to_be_bytes());
+ cases.push(("negative_column_count", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[23..27].copy_from_slice(&(-1i32).to_be_bytes());
+ cases.push(("negative_index_count", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[22] = 0x80;
+ cases.push(("invalid_modified_utf8", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[30..34].copy_from_slice(&(-2i32).to_be_bytes());
+ cases.push(("negative_index_start", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[34..38].copy_from_slice(&(-1i32).to_be_bytes());
+ cases.push(("negative_index_length", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[30..34].copy_from_slice(&EMPTY_INDEX_FLAG.to_be_bytes());
+ bytes[34..38].copy_from_slice(&1i32.to_be_bytes());
+ cases.push(("non_empty_sentinel", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[30..34].copy_from_slice(&41i32.to_be_bytes());
+ cases.push(("range_overlaps_header", bytes));
+
+ let mut bytes = valid.clone();
+ bytes[34..38].copy_from_slice(&4i32.to_be_bytes());
+ cases.push(("range_exceeds_file", bytes));
+
+ let mut bytes = valid;
+ bytes[38..42].copy_from_slice(&1i32.to_be_bytes());
+ cases.push(("missing_redundant_byte", bytes));
+
+ for (name, bytes) in cases {
+ let input = write_fixture(&format!("memory:/tmp/{name}"),
bytes).await?;
+ let error = match
FileIndexFormatReader::get_file_index(input).await {
+ Ok(_) => panic!("malformed case {name} must fail"),
+ Err(error) => error,
+ };
+ assert!(matches!(error, Error::FileIndexFormatInvalid { .. }));
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_large_header_round_trip() -> crate::Result<()> {
+ let mut column_indexes = HashMap::new();
+ for index in 0..18 {
+ let name = format!("{index:02}{}", "x".repeat(59_998));
+ column_indexes.insert(name, Some(Bytes::from(vec![index as u8])));
+ }
+ let expected = column_indexes.clone();
+ let indexes = HashMap::from([("column".to_string(), column_indexes)]);
+
+ let output = write_column_indexes("memory:/tmp/large_header",
indexes).await?;
+ let raw = output.clone().to_input_file().read().await?;
+ assert!(i32::from_be_bytes(raw[12..16].try_into().unwrap()) > 1024 *
1024);
+
+ let reader =
FileIndexFormatReader::get_file_index(output.to_input_file()).await?;
+ assert_eq!(reader.get_column_index("column").await?, expected);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_writer_rejects_overlong_modified_utf8_name() {
+ let indexes =
+ HashMap::from([("x".repeat(65_536), HashMap::<String,
Option<Bytes>>::new())]);
+
+ let error = match write_column_indexes("memory:/tmp/overlong_name",
indexes).await {
+ Ok(_) => panic!("overlong name must fail"),
+ Err(error) => error,
+ };
+
+ assert!(matches!(error, Error::FileIndexFormatInvalid { .. }));
+ }
+
#[tokio::test]
async fn test_single_column_single_index() -> crate::Result<()> {
let path = "memory:/tmp/test_single_column_single_index";
let mut indexes = HashMap::new();
let mut index_map = HashMap::new();
- index_map.insert("index1".to_string(), Bytes::from("sample_data"));
+ index_map.insert("index1".to_string(),
Some(Bytes::from("sample_data")));
indexes.insert("column111".to_string(), index_map);
let output = write_column_indexes(path, indexes.clone()).await?;
@@ -422,7 +773,7 @@ mod file_index_format_tests {
let column_data = reader.get_column_index("column111").await?;
assert_eq!(
column_data.get("index1").unwrap(),
- &Bytes::from("sample_data")
+ &Some(Bytes::from("sample_data"))
);
Ok(())
@@ -439,7 +790,7 @@ mod file_index_format_tests {
for idx_num in 1..5 {
index_map.insert(
format!("index{idx_num}"),
- random_bytes(100 + col_num * idx_num),
+ Some(random_bytes(100 + col_num * idx_num)),
);
}
indexes.insert(column_name, index_map);
@@ -466,8 +817,8 @@ mod file_index_format_tests {
let mut indexes = HashMap::new();
let mut a_index = HashMap::new();
- a_index.insert("b".to_string(), Bytes::new());
- a_index.insert("c".to_string(), Bytes::new());
+ a_index.insert("b".to_string(), None);
+ a_index.insert("c".to_string(), Some(Bytes::new()));
indexes.insert("a".to_string(), a_index);
let output = write_column_indexes(path, indexes.clone()).await?;
@@ -478,8 +829,8 @@ mod file_index_format_tests {
let column_indexes = reader.get_column_index("a").await?;
assert_eq!(column_indexes.len(), 2);
- assert_eq!(column_indexes.get("b").unwrap(), &Bytes::new());
- assert_eq!(column_indexes.get("c").unwrap(), &Bytes::new());
+ assert_eq!(column_indexes.get("b"), Some(&None));
+ assert_eq!(column_indexes.get("c"), Some(&Some(Bytes::new())));
Ok(())
}
@@ -490,7 +841,7 @@ mod file_index_format_tests {
let mut indexes = HashMap::new();
let mut large_data = HashMap::new();
- large_data.insert("large_index".to_string(),
random_bytes(100_000_000)); // 100MB data
+ large_data.insert("large_index".to_string(),
Some(random_bytes(100_000_000))); // 100MB data
indexes.insert("large_column".to_string(), large_data);
write_column_indexes(path, indexes.clone()).await?;
@@ -502,12 +853,8 @@ mod file_index_format_tests {
let reader = FileIndexFormatReader::get_file_index(input).await?;
let column_data = reader.get_column_index("large_column").await?;
assert_eq!(
- column_data.get("large_index").unwrap(),
- &indexes
- .get("large_column")
- .unwrap()
- .get("large_index")
- .unwrap()
+ column_data.get("large_index"),
+ indexes.get("large_column").unwrap().get("large_index")
);
Ok(())