use crate::{Error, Result};
use std::io::{Cursor, Read};
pub const SUPPORTED_COMPRESSOR_NAMES: &[&str] = &[
"LZ4Compressor",
"SnappyCompressor",
"DeflateCompressor",
"ZstdCompressor",
"NoopCompressor",
];
pub fn is_supported_compressor_name(name: &str) -> bool {
let simple = name.rsplit('.').next().unwrap_or(name);
SUPPORTED_COMPRESSOR_NAMES.contains(&simple)
}
#[derive(Debug, Clone)]
pub struct CompressionInfo {
pub algorithm: String,
pub option_pairs: Vec<(String, String)>,
pub chunk_length: u32,
pub max_compressed_length: u32,
pub data_length: u64,
pub chunk_offsets: Vec<u64>,
}
fn read_utf(cursor: &mut Cursor<&[u8]>) -> Result<String> {
let mut len_bytes = [0u8; 2];
cursor
.read_exact(&mut len_bytes)
.map_err(|e| Error::InvalidFormat(format!("Failed to read writeUTF length: {}", e)))?;
let len = u16::from_be_bytes(len_bytes) as usize;
let mut string_bytes = vec![0u8; len];
cursor.read_exact(&mut string_bytes).map_err(|e| {
Error::InvalidFormat(format!(
"Failed to read writeUTF bytes (len={}): {}",
len, e
))
})?;
String::from_utf8(string_bytes)
.map_err(|e| Error::InvalidFormat(format!("Invalid UTF-8 in writeUTF string: {}", e)))
}
fn read_u32(cursor: &mut Cursor<&[u8]>, field: &str) -> Result<u32> {
let mut bytes = [0u8; 4];
cursor
.read_exact(&mut bytes)
.map_err(|e| Error::InvalidFormat(format!("Failed to read {} (u32 BE): {}", field, e)))?;
Ok(u32::from_be_bytes(bytes))
}
fn read_u64(cursor: &mut Cursor<&[u8]>, field: &str) -> Result<u64> {
let mut bytes = [0u8; 8];
cursor
.read_exact(&mut bytes)
.map_err(|e| Error::InvalidFormat(format!("Failed to read {} (u64 BE): {}", field, e)))?;
Ok(u64::from_be_bytes(bytes))
}
impl CompressionInfo {
pub fn parse(data: &[u8]) -> Result<Self> {
crate::storage::sstable::read_work_counters::record_compression_info_parse();
if data.is_empty() {
return Err(Error::InvalidFormat(
"Empty compression info data".to_string(),
));
}
let mut cursor = Cursor::new(data);
let algorithm = read_utf(&mut cursor)?;
if algorithm.is_empty() {
return Err(Error::InvalidFormat(
"Compressor simple name is empty".to_string(),
));
}
if !is_supported_compressor_name(&algorithm) {
return Err(Error::UnsupportedFormat(format!(
"Unsupported compression algorithm '{}' in CompressionInfo.db. \
CQLite only supports: {}. Cannot decompress this SSTable.",
algorithm,
SUPPORTED_COMPRESSOR_NAMES.join(", ")
)));
}
let algorithm = algorithm
.rsplit('.')
.next()
.unwrap_or(&algorithm)
.to_string();
let option_count = read_u32(&mut cursor, "option_count")?;
if option_count > 256 {
return Err(Error::InvalidFormat(format!(
"Unreasonably large option_count: {} (max 256)",
option_count
)));
}
let mut option_pairs = Vec::with_capacity(option_count as usize);
for i in 0..option_count {
let key = read_utf(&mut cursor).map_err(|e| {
Error::InvalidFormat(format!("Failed to read option key {}: {}", i, e))
})?;
let value = read_utf(&mut cursor).map_err(|e| {
Error::InvalidFormat(format!("Failed to read option value {}: {}", i, e))
})?;
option_pairs.push((key, value));
}
let chunk_length = read_u32(&mut cursor, "chunk_length")?;
if chunk_length == 0 {
return Err(Error::InvalidFormat(
"chunk_length cannot be zero".to_string(),
));
}
if chunk_length > 256 * 1024 * 1024 {
return Err(Error::InvalidFormat(format!(
"chunk_length too large: {} bytes (max 256 MiB)",
chunk_length
)));
}
let max_compressed_length = read_u32(&mut cursor, "max_compressed_length")?;
let data_length = read_u64(&mut cursor, "data_length")?;
let chunk_count = read_u32(&mut cursor, "chunk_count")? as usize;
if chunk_count == 0 {
return Err(Error::InvalidFormat(
"chunk_count cannot be zero".to_string(),
));
}
if chunk_count > 1_000_000 {
return Err(Error::InvalidFormat(format!(
"chunk_count too large: {} (max 1,000,000)",
chunk_count
)));
}
let mut chunk_offsets = Vec::with_capacity(chunk_count);
for i in 0..chunk_count {
let offset = read_u64(&mut cursor, &format!("chunk_offset[{}]", i))?;
chunk_offsets.push(offset);
}
let info = CompressionInfo {
algorithm,
option_pairs,
chunk_length,
max_compressed_length,
data_length,
chunk_offsets,
};
info.validate()?;
Ok(info)
}
pub fn algorithm_enum(
&self,
) -> Result<crate::storage::sstable::compression::CompressionAlgorithm> {
crate::storage::sstable::compression::CompressionAlgorithm::parse(&self.algorithm)
}
pub fn zstd_dictionary_option(&self) -> Option<&str> {
self.option_pairs
.iter()
.find(|(k, _)| k.to_ascii_lowercase().contains("dictionary"))
.map(|(k, _)| k.as_str())
}
pub fn chunk_for_offset(&self, offset: u64) -> usize {
(offset / self.chunk_length as u64) as usize
}
pub fn offset_within_chunk(&self, offset: u64) -> u64 {
offset % self.chunk_length as u64
}
pub fn compressed_chunk_offset(&self, chunk_index: usize) -> Option<u64> {
self.chunk_offsets.get(chunk_index).copied()
}
pub fn compressed_chunk_size(
&self,
chunk_index: usize,
total_compressed_size: u64,
) -> Option<u64> {
let start_offset = self.compressed_chunk_offset(chunk_index)?;
if chunk_index + 1 < self.chunk_offsets.len() {
let next_offset = self.chunk_offsets[chunk_index + 1];
next_offset.checked_sub(start_offset)
} else {
total_compressed_size.checked_sub(start_offset)
}
}
pub fn validate(&self) -> Result<()> {
if self.algorithm.is_empty() {
return Err(Error::InvalidFormat(
"Empty compression algorithm".to_string(),
));
}
if self.chunk_length == 0 {
return Err(Error::InvalidFormat("Zero chunk length".to_string()));
}
if self.chunk_offsets.is_empty() {
return Err(Error::InvalidFormat("No chunk offsets".to_string()));
}
for i in 1..self.chunk_offsets.len() {
if self.chunk_offsets[i] <= self.chunk_offsets[i - 1] {
return Err(Error::InvalidFormat(format!(
"Chunk offsets not in ascending order: offsets[{}]={} <= offsets[{}]={}",
i,
self.chunk_offsets[i],
i - 1,
self.chunk_offsets[i - 1]
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_compression_info_blob(
algorithm: &str,
options: &[(&str, &str)],
chunk_length: u32,
max_compressed_length: u32,
data_length: u64,
offsets: &[u64],
) -> Vec<u8> {
let mut data = Vec::new();
let name_bytes = algorithm.as_bytes();
data.extend_from_slice(&(name_bytes.len() as u16).to_be_bytes());
data.extend_from_slice(name_bytes);
data.extend_from_slice(&(options.len() as u32).to_be_bytes());
for (k, v) in options {
let kb = k.as_bytes();
data.extend_from_slice(&(kb.len() as u16).to_be_bytes());
data.extend_from_slice(kb);
let vb = v.as_bytes();
data.extend_from_slice(&(vb.len() as u16).to_be_bytes());
data.extend_from_slice(vb);
}
data.extend_from_slice(&chunk_length.to_be_bytes());
data.extend_from_slice(&max_compressed_length.to_be_bytes());
data.extend_from_slice(&data_length.to_be_bytes());
data.extend_from_slice(&(offsets.len() as u32).to_be_bytes());
for &off in offsets {
data.extend_from_slice(&off.to_be_bytes());
}
data
}
#[test]
fn test_parse_no_options() {
let blob = make_compression_info_blob(
"LZ4Compressor",
&[],
16384,
i32::MAX as u32,
32768,
&[0, 8200],
);
let info = CompressionInfo::parse(&blob).expect("parse should succeed");
assert_eq!(info.algorithm, "LZ4Compressor");
assert!(info.option_pairs.is_empty());
assert_eq!(info.chunk_length, 16384);
assert_eq!(info.max_compressed_length, i32::MAX as u32);
assert_eq!(info.data_length, 32768);
assert_eq!(info.chunk_offsets, vec![0, 8200]);
}
#[test]
fn test_parse_with_options() {
let blob = make_compression_info_blob(
"LZ4Compressor",
&[("compression_level", "9")],
16384,
i32::MAX as u32,
16384,
&[0],
);
let info = CompressionInfo::parse(&blob).expect(
"Bug #638 repro: parser must handle option_count > 0 deterministically, \
not skip 4 bytes as padding",
);
assert_eq!(info.algorithm, "LZ4Compressor");
assert_eq!(info.option_pairs.len(), 1);
assert_eq!(info.option_pairs[0].0, "compression_level");
assert_eq!(info.option_pairs[0].1, "9");
assert_eq!(info.chunk_length, 16384);
assert_eq!(info.max_compressed_length, i32::MAX as u32);
}
#[test]
fn test_parse_exposes_max_compressed_length() {
let blob = make_compression_info_blob(
"SnappyCompressor",
&[],
16384,
i32::MAX as u32,
16384,
&[0],
);
let info = CompressionInfo::parse(&blob).expect("parse should succeed");
assert_eq!(
info.max_compressed_length,
i32::MAX as u32,
"Bug #638: max_compressed_length field must be exposed for incompressible-chunk fallback"
);
}
#[test]
fn test_parse_real_snappy_fixture() {
let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test-data/datasets/sstables/test_basic/simple_table-6aa08200a25111f0a3fef1a551383fb9/nb-1-big-CompressionInfo.db");
if !fixture_path.exists() {
println!("Skipping real-fixture test: {}", fixture_path.display());
return;
}
let data = std::fs::read(&fixture_path).expect("read fixture");
let info = CompressionInfo::parse(&data).expect("parse real CompressionInfo.db");
assert_eq!(info.algorithm, "SnappyCompressor");
assert_eq!(info.chunk_length, 16384);
assert_eq!(info.max_compressed_length, i32::MAX as u32);
assert!(!info.chunk_offsets.is_empty());
for i in 1..info.chunk_offsets.len() {
assert!(
info.chunk_offsets[i] > info.chunk_offsets[i - 1],
"offsets must be increasing"
);
}
let expected_size: usize = 2
+ info.algorithm.len()
+ 4 + 4 + 4 + 8 + 4 + info.chunk_offsets.len() * 8;
assert_eq!(
data.len(),
expected_size,
"CompressionInfo.db must end immediately after offsets — no CRC bytes appended"
);
}
#[test]
fn test_chunk_calculations() {
let info = CompressionInfo {
algorithm: "LZ4Compressor".to_string(),
option_pairs: vec![],
chunk_length: 16384,
max_compressed_length: i32::MAX as u32,
data_length: 32768,
chunk_offsets: vec![0, 8192],
};
assert_eq!(info.chunk_for_offset(0), 0);
assert_eq!(info.chunk_for_offset(16384), 1);
assert_eq!(info.offset_within_chunk(100), 100);
assert_eq!(info.offset_within_chunk(16484), 100);
assert_eq!(info.compressed_chunk_offset(0), Some(0));
assert_eq!(info.compressed_chunk_offset(1), Some(8192));
assert_eq!(info.compressed_chunk_size(0, 20000), Some(8192));
assert_eq!(info.compressed_chunk_size(1, 20000), Some(11808));
}
#[test]
fn test_validation() {
let valid_info = CompressionInfo {
algorithm: "LZ4Compressor".to_string(),
option_pairs: vec![],
chunk_length: 16384,
max_compressed_length: i32::MAX as u32,
data_length: 32768,
chunk_offsets: vec![0, 8192],
};
assert!(valid_info.validate().is_ok());
let invalid = CompressionInfo {
algorithm: "".to_string(),
option_pairs: vec![],
chunk_length: 16384,
max_compressed_length: i32::MAX as u32,
data_length: 0,
chunk_offsets: vec![0],
};
assert!(invalid.validate().is_err());
let invalid2 = CompressionInfo {
algorithm: "LZ4Compressor".to_string(),
option_pairs: vec![],
chunk_length: 16384,
max_compressed_length: i32::MAX as u32,
data_length: 32768,
chunk_offsets: vec![8192, 0],
};
assert!(invalid2.validate().is_err());
}
}