use std::path::Path;
use crate::{Error, Result};
pub(crate) const MAX_CRC_CHUNK_SIZE: u32 = 16 * 1024 * 1024;
pub(crate) const MIN_CRC_CHUNK_SIZE: u32 = 4096;
pub(crate) const ABSOLUTE_CRC_DB_MAX: u64 = 64 * 1024 * 1024;
fn validate_chunk_size(chunk_size_raw: i32) -> Result<u32> {
if chunk_size_raw <= 0 {
return Err(Error::corruption(format!(
"CRC.db chunk-size header is non-positive ({chunk_size_raw}); expected a positive block size (Cassandra default 65536)"
)));
}
let chunk_size = chunk_size_raw as u32;
if chunk_size < MIN_CRC_CHUNK_SIZE {
return Err(Error::corruption(format!(
"CRC.db chunk-size header is {chunk_size} bytes — below the {MIN_CRC_CHUNK_SIZE}-byte minimum (Cassandra uses 65536); a chunk size this small implies an unbounded CRC entry count"
)));
}
if chunk_size > MAX_CRC_CHUNK_SIZE {
return Err(Error::corruption(format!(
"CRC.db chunk-size header is {chunk_size} bytes — exceeds the {MAX_CRC_CHUNK_SIZE}-byte maximum (Cassandra default 65536); refusing to allocate an unbounded verification buffer"
)));
}
Ok(chunk_size)
}
#[derive(Debug, Clone)]
pub(crate) struct CrcDb {
chunk_size: u32,
crcs: Vec<u32>,
}
impl CrcDb {
pub(crate) fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < 4 {
return Err(Error::corruption(format!(
"CRC.db is {} bytes — too short for the 4-byte chunk-size header",
bytes.len()
)));
}
let chunk_size_raw = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let chunk_size = validate_chunk_size(chunk_size_raw)?;
let body = &bytes[4..];
if body.len() % 4 != 0 {
return Err(Error::corruption(format!(
"CRC.db body is {} bytes — not a whole number of 4-byte CRC32 entries (truncated)",
body.len()
)));
}
let crcs = body
.chunks_exact(4)
.map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok(Self { chunk_size, crcs })
}
pub(crate) async fn open(path: &Path, data_len: u64) -> Result<Self> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await.map_err(|e| {
Error::corruption(format!("cannot open CRC.db at {}: {}", path.display(), e))
})?;
let mut header = [0u8; 4];
file.read_exact(&mut header).await.map_err(|e| {
Error::corruption(format!(
"CRC.db at {} is too short for the 4-byte chunk-size header: {}",
path.display(),
e
))
})?;
let chunk_size_raw = i32::from_be_bytes(header);
let chunk_size = validate_chunk_size(chunk_size_raw)?;
let actual_len = tokio::fs::metadata(path)
.await
.map(|m| m.len())
.map_err(|e| {
Error::corruption(format!("cannot stat CRC.db at {}: {}", path.display(), e))
})?;
if actual_len > ABSOLUTE_CRC_DB_MAX {
return Err(Error::corruption(format!(
"CRC.db at {} is {actual_len} bytes — exceeds the absolute {ABSOLUTE_CRC_DB_MAX}-byte ceiling (enough CRC entries for a 1 TiB Data.db at a 64 KiB chunk size); refusing to read an oversized sidecar",
path.display()
)));
}
let n_chunks = data_len.div_ceil(chunk_size as u64);
let max_len = 4u64.saturating_add(n_chunks.saturating_add(1).saturating_mul(4));
if actual_len > max_len {
return Err(Error::corruption(format!(
"CRC.db at {} is {actual_len} bytes — exceeds the {max_len}-byte maximum implied by a {data_len}-byte Data.db (chunk_size={chunk_size}, {n_chunks} chunks + optional trailer); refusing to read an oversized sidecar",
path.display()
)));
}
let bytes = tokio::fs::read(path).await.map_err(|e| {
Error::corruption(format!("cannot read CRC.db at {}: {}", path.display(), e))
})?;
Self::parse(&bytes)
}
pub(crate) fn chunk_size(&self) -> u32 {
self.chunk_size
}
pub(crate) fn chunk_count(&self) -> usize {
self.crcs.len()
}
pub(crate) fn crc_for_chunk(&self, chunk_index: usize) -> Result<u32> {
self.crcs.get(chunk_index).copied().ok_or_else(|| {
Error::corruption(format!(
"CRC.db is truncated: no CRC32 entry for chunk {} (file position {}); it has only {} entries",
chunk_index,
chunk_index * 4 + 4,
self.crcs.len()
))
})
}
#[cfg(test)]
pub(crate) fn crc_for_offset(&self, offset: u64) -> Result<u32> {
let chunk_index = (offset / self.chunk_size as u64) as usize;
self.crc_for_chunk(chunk_index)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "write-support")]
use crate::storage::sstable::writer::crc_writer::{write_crc_db, CrcTrailer, CRC_CHUNK_SIZE};
fn synth_crc_db(chunk_size: u32, crcs: &[u32]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&(chunk_size as i32).to_be_bytes());
for c in crcs {
v.extend_from_slice(&c.to_be_bytes());
}
v
}
#[test]
fn parses_header_and_entries() {
let bytes = synth_crc_db(65536, &[0xf9e0_9e7f, 0x0000_0001, 0xdead_beef]);
let crc = CrcDb::parse(&bytes).expect("parse");
assert_eq!(crc.chunk_size(), 65536);
assert_eq!(crc.chunk_count(), 3);
assert_eq!(crc.crc_for_chunk(0).unwrap(), 0xf9e0_9e7f);
assert_eq!(crc.crc_for_chunk(2).unwrap(), 0xdead_beef);
}
#[test]
fn offset_maps_to_correct_chunk() {
let cs = 64u32 * 1024;
let bytes = synth_crc_db(cs, &[10, 20, 30]);
let crc = CrcDb::parse(&bytes).expect("parse");
assert_eq!(crc.crc_for_offset(0).unwrap(), 10);
assert_eq!(crc.crc_for_offset(cs as u64 - 1).unwrap(), 10);
assert_eq!(crc.crc_for_offset(cs as u64).unwrap(), 20);
assert_eq!(crc.crc_for_offset(2 * cs as u64 + 5).unwrap(), 30);
}
#[test]
fn missing_header_is_typed_error_not_panic() {
for len in 0..4 {
let err = CrcDb::parse(&vec![0u8; len]).unwrap_err();
assert!(matches!(err, Error::Corruption(_)), "len {len}: {err}");
}
}
#[test]
fn non_positive_chunk_size_is_typed_error() {
let bytes = synth_crc_db(0, &[]);
assert!(matches!(CrcDb::parse(&bytes), Err(Error::Corruption(_))));
let neg = [0x80u8, 0, 0, 0];
assert!(matches!(CrcDb::parse(&neg), Err(Error::Corruption(_))));
}
#[test]
fn absurd_chunk_size_is_typed_error_not_oom() {
let bytes = synth_crc_db(i32::MAX as u32, &[]);
let err = CrcDb::parse(&bytes).expect_err("absurd chunk size must error");
assert!(
matches!(err, Error::Corruption(_)),
"typed corruption: {err}"
);
assert!(
err.to_string().contains("maximum"),
"message names the maximum bound: {err}"
);
assert!(CrcDb::parse(&synth_crc_db(MAX_CRC_CHUNK_SIZE, &[])).is_ok());
assert!(matches!(
CrcDb::parse(&synth_crc_db(MAX_CRC_CHUNK_SIZE + 1, &[])),
Err(Error::Corruption(_))
));
}
#[tokio::test]
async fn oversized_crc_db_vs_data_len_is_typed_error_before_body_read() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
tokio::fs::write(&data, b"tiny").await.expect("write data");
let data_len = 4u64;
let bogus: Vec<u32> = (0..10_000u32).collect();
let crc_path = dir.path().join("CRC.db");
tokio::fs::write(&crc_path, synth_crc_db(65536, &bogus))
.await
.expect("write oversized crc.db");
let err = CrcDb::open(&crc_path, data_len)
.await
.expect_err("oversized CRC.db must be rejected");
assert!(
matches!(err, Error::Corruption(_)),
"typed corruption: {err}"
);
assert!(
err.to_string().contains("maximum") && err.to_string().contains("exceeds"),
"error must name the derived maximum bound: {err}"
);
let ok_path = dir.path().join("CRC-ok.db");
tokio::fs::write(&ok_path, synth_crc_db(65536, &[0xdead_beef]))
.await
.expect("write ok crc.db");
CrcDb::open(&ok_path, data_len)
.await
.expect("correctly-sized CRC.db must open");
}
#[tokio::test]
async fn tiny_chunk_size_is_rejected_before_large_alloc() {
let bytes = synth_crc_db(1, &[0xdead_beef]);
let err = CrcDb::parse(&bytes).expect_err("chunk_size=1 must be rejected");
assert!(
matches!(err, Error::Corruption(_)),
"typed corruption: {err}"
);
assert!(
err.to_string().contains("minimum"),
"message names the minimum-chunk-size bound: {err}"
);
let dir = tempfile::tempdir().expect("tempdir");
let crc_path = dir.path().join("CRC.db");
tokio::fs::write(&crc_path, synth_crc_db(1, &[0, 1, 2]))
.await
.expect("write tiny-chunk crc.db");
let huge_data_len = 4u64 * 1024 * 1024 * 1024;
let err = CrcDb::open(&crc_path, huge_data_len)
.await
.expect_err("tiny chunk size must be rejected at open");
assert!(
matches!(err, Error::Corruption(_)),
"typed corruption: {err}"
);
assert!(
err.to_string().contains("minimum"),
"open error must name the min-chunk-size bound: {err}"
);
assert!(CrcDb::parse(&synth_crc_db(MIN_CRC_CHUNK_SIZE, &[])).is_ok());
assert!(matches!(
CrcDb::parse(&synth_crc_db(MIN_CRC_CHUNK_SIZE - 1, &[])),
Err(Error::Corruption(_))
));
}
#[tokio::test]
async fn crc_db_over_absolute_ceiling_is_rejected_before_body_read() {
use tokio::io::AsyncWriteExt;
let dir = tempfile::tempdir().expect("tempdir");
let crc_path = dir.path().join("CRC.db");
{
let mut f = tokio::fs::File::create(&crc_path).await.expect("create");
f.write_all(&65536i32.to_be_bytes())
.await
.expect("write header");
f.flush().await.expect("flush");
f.set_len(ABSOLUTE_CRC_DB_MAX + 4)
.await
.expect("set_len over ceiling");
}
let huge_data_len = 100u64 * 1024 * 1024 * 1024 * 1024;
let err = CrcDb::open(&crc_path, huge_data_len)
.await
.expect_err("over-ceiling CRC.db must be rejected");
assert!(
matches!(err, Error::Corruption(_)),
"typed corruption: {err}"
);
assert!(
err.to_string().contains("absolute"),
"error must name the absolute ceiling: {err}"
);
}
#[test]
fn partial_trailing_crc_is_typed_error() {
let mut bytes = synth_crc_db(65536, &[42]);
bytes.extend_from_slice(&[0x01, 0x02]);
assert!(matches!(CrcDb::parse(&bytes), Err(Error::Corruption(_))));
}
#[test]
fn truncated_missing_entry_errors_for_covered_chunk() {
let bytes = synth_crc_db(65536, &[7]);
let crc = CrcDb::parse(&bytes).expect("parse");
assert!(matches!(crc.crc_for_chunk(2), Err(Error::Corruption(_))));
}
#[cfg(feature = "write-support")]
#[tokio::test]
async fn round_trips_the_writer_output_multi_chunk() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
let len = CRC_CHUNK_SIZE * 2 + CRC_CHUNK_SIZE / 2;
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
tokio::fs::write(&data, &payload).await.expect("write data");
let crc_path = dir.path().join("CRC.db");
write_crc_db(&data, crc_path.clone(), CrcTrailer::None)
.await
.expect("write crc.db");
let crc = CrcDb::open(&crc_path, len as u64)
.await
.expect("open crc.db");
assert_eq!(crc.chunk_size() as usize, CRC_CHUNK_SIZE);
assert_eq!(crc.chunk_count(), 3, "2.5 chunks -> 3 CRC entries");
for (i, chunk) in payload.chunks(CRC_CHUNK_SIZE).enumerate() {
let expected = crc32fast::hash(chunk);
assert_eq!(
crc.crc_for_chunk(i).unwrap(),
expected,
"chunk {i} CRC must match crc32fast over its raw bytes"
);
}
}
#[tokio::test]
async fn parses_real_cassandra_crc_db_and_byte_agrees_with_data_db() {
use std::path::PathBuf;
let root = std::env::var("CQLITE_DATASETS_ROOT")
.ok()
.map(PathBuf::from)
.filter(|p| p.is_dir())
.or_else(|| {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()?
.join("test-data/datasets");
p.is_dir().then_some(p)
});
let Some(root) = root else {
eprintln!("SKIP: no datasets root for the real CRC.db byte-agreement test.");
return;
};
let mut found: Option<(PathBuf, PathBuf)> = None;
for ks in ["test_basic", "test_comp"] {
let base = root.join("sstables").join(ks);
let Ok(rd) = std::fs::read_dir(&base) else {
continue;
};
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with("uncompressed_table-") {
continue;
}
let crc = entry.path().join("nb-1-big-CRC.db");
let data = entry.path().join("nb-1-big-Data.db");
if crc.is_file() && data.is_file() {
found = Some((crc, data));
break;
}
}
if found.is_some() {
break;
}
}
let Some((crc_path, data_path)) = found else {
eprintln!(
"SKIP: no Cassandra-written uncompressed_table CRC.db+Data.db fixture found."
);
return;
};
let data_len = std::fs::metadata(&data_path)
.map(|m| m.len())
.expect("stat Data.db");
let crc = CrcDb::open(&crc_path, data_len)
.await
.expect("parse real CRC.db");
assert_eq!(
crc.chunk_size(),
65536,
"Cassandra uncompressed CRC.db chunk size must be 65536 (0x00010000)"
);
let data = tokio::fs::read(&data_path).await.expect("read Data.db");
let cs = crc.chunk_size() as usize;
let expected_chunks = data.len().div_ceil(cs);
assert!(
crc.chunk_count() >= expected_chunks,
"CRC.db must have at least one entry per Data.db chunk ({} >= {})",
crc.chunk_count(),
expected_chunks
);
for (i, block) in data.chunks(cs).enumerate() {
let recomputed = crc32fast::hash(block);
assert_eq!(
crc.crc_for_chunk(i).unwrap(),
recomputed,
"chunk {i}: stored CRC.db value must byte-agree with CRC32 over the raw Data.db chunk"
);
}
}
#[cfg(feature = "write-support")]
#[tokio::test]
async fn compaction_trailer_is_extra_harmless_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path().join("Data.db");
let payload: &[u8] = b"one short chunk";
tokio::fs::write(&data, payload).await.expect("write");
let crc_path = dir.path().join("CRC.db");
write_crc_db(&data, crc_path.clone(), CrcTrailer::EmptyFinalChunk)
.await
.expect("write");
let crc = CrcDb::open(&crc_path, payload.len() as u64)
.await
.expect("open");
assert_eq!(crc.chunk_count(), 2);
assert_eq!(crc.crc_for_chunk(1).unwrap(), 0);
}
}