use crate::platform::Platform;
use crate::storage::sstable::compression_info::CompressionInfo;
use crate::storage::sstable::reader::{extract_sstable_base_name, SSTableReader};
use crate::storage::sstable::version_gate::{SsTableDescriptor, SsTableFormat};
use crate::{Config, Error, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyMode {
Quick,
Full,
}
impl VerifyMode {
pub fn as_str(self) -> &'static str {
match self {
VerifyMode::Quick => "quick",
VerifyMode::Full => "full",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VerifyErrorClass {
MissingComponent,
DigestMismatch,
CompressionInfoCorrupt,
ChunkOffsetOutOfBounds,
ChunkDecompressionError,
UnsupportedCompressionFeature,
UncompressedChunkCrcMismatch,
UnexpectedEof,
IndexEntryCorrupt,
StatisticsHeaderCorrupt,
SummaryCorrupt,
BtiRootPointerCorrupt,
BtiTrieCorrupt,
RowScanFailed,
OutOfOrderKeyOrRow,
InvalidLocalDeletionTime,
FilterFalseNegative,
}
impl VerifyErrorClass {
pub fn code(self) -> &'static str {
match self {
VerifyErrorClass::MissingComponent => "MissingComponent",
VerifyErrorClass::DigestMismatch => "DigestMismatch",
VerifyErrorClass::CompressionInfoCorrupt => "CompressionInfoCorrupt",
VerifyErrorClass::ChunkOffsetOutOfBounds => "ChunkOffsetOutOfBounds",
VerifyErrorClass::ChunkDecompressionError => "ChunkDecompressionError",
VerifyErrorClass::UnsupportedCompressionFeature => "UnsupportedCompressionFeature",
VerifyErrorClass::UncompressedChunkCrcMismatch => "UncompressedChunkCrcMismatch",
VerifyErrorClass::UnexpectedEof => "UnexpectedEof",
VerifyErrorClass::IndexEntryCorrupt => "IndexEntryCorrupt",
VerifyErrorClass::StatisticsHeaderCorrupt => "StatisticsHeaderCorrupt",
VerifyErrorClass::SummaryCorrupt => "SummaryCorrupt",
VerifyErrorClass::BtiRootPointerCorrupt => "BtiRootPointerCorrupt",
VerifyErrorClass::BtiTrieCorrupt => "BtiTrieCorrupt",
VerifyErrorClass::RowScanFailed => "RowScanFailed",
VerifyErrorClass::OutOfOrderKeyOrRow => "OutOfOrderKeyOrRow",
VerifyErrorClass::InvalidLocalDeletionTime => "InvalidLocalDeletionTime",
VerifyErrorClass::FilterFalseNegative => "FilterFalseNegative",
}
}
}
impl std::fmt::Display for VerifyErrorClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyFinding {
pub class: VerifyErrorClass,
pub component: String,
pub detail: String,
}
impl VerifyFinding {
fn new(
class: VerifyErrorClass,
component: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
class,
component: component.into(),
detail: detail.into(),
}
}
}
impl std::fmt::Display for VerifyFinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {}: {}",
self.class.code(),
self.component,
self.detail
)
}
}
#[derive(Debug, Clone)]
pub struct VerifyReport {
pub directory: PathBuf,
pub base_name: String,
pub format: SsTableFormat,
pub mode: VerifyMode,
pub findings: Vec<VerifyFinding>,
pub toc_components: Vec<String>,
pub rows_scanned: Option<usize>,
}
impl VerifyReport {
pub fn is_ok(&self) -> bool {
self.findings.is_empty()
}
pub fn primary_class(&self) -> Option<VerifyErrorClass> {
self.findings.first().map(|f| f.class)
}
pub fn summary_line(&self) -> String {
if self.is_ok() {
format!(
"VERIFY OK [{}/{}] {} ({} rows)",
self.mode.as_str(),
self.format.as_str(),
self.base_name,
self.rows_scanned
.map(|n| n.to_string())
.unwrap_or_else(|| "-".to_string()),
)
} else {
format!(
"VERIFY FAIL [{}/{}] {}: {}",
self.mode.as_str(),
self.format.as_str(),
self.base_name,
self.findings
.iter()
.map(|f| f.to_string())
.collect::<Vec<_>>()
.join("; "),
)
}
}
}
struct ComponentSet {
base_name: String,
format: SsTableFormat,
present: BTreeMap<String, PathBuf>,
data_path: PathBuf,
}
impl ComponentSet {
fn path(&self, dir: &Path, component: &str) -> PathBuf {
dir.join(format!("{}-{}", self.base_name, component))
}
fn has(&self, component: &str) -> bool {
self.present.contains_key(component)
}
}
pub async fn verify_sstable(
dir: &Path,
mode: VerifyMode,
config: &Config,
platform: Arc<Platform>,
) -> Result<VerifyReport> {
let components = resolve_components(dir)?;
verify_components(dir, components, mode, config, platform).await
}
pub async fn verify_sstable_generation(
data_db_path: &Path,
mode: VerifyMode,
config: &Config,
platform: Arc<Platform>,
) -> Result<VerifyReport> {
let dir = generation_dir(data_db_path);
let components = resolve_components_for_data_path(dir, data_db_path)?;
verify_components(dir, components, mode, config, platform).await
}
fn generation_dir(data_db_path: &Path) -> &Path {
match data_db_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
}
}
async fn verify_components(
dir: &Path,
components: ComponentSet,
mode: VerifyMode,
config: &Config,
platform: Arc<Platform>,
) -> Result<VerifyReport> {
let mut findings: Vec<VerifyFinding> = Vec::new();
let toc_components = check_toc_and_presence(dir, &components, &mut findings)?;
check_digest(dir, &components, &mut findings)?;
let compression_info = check_compression_info(dir, &components, &mut findings)?;
let mut bti_leaves: Option<Vec<BtiResolvedLeaf>> = None;
match components.format {
SsTableFormat::Bti => bti_leaves = check_bti_structure(dir, &components, &mut findings)?,
SsTableFormat::Big => check_big_index(dir, &components, &mut findings)?,
}
let mut rows_scanned = None;
if mode == VerifyMode::Full {
if let Some(info) = compression_info.as_ref() {
check_inline_chunk_crc(&components, info, &mut findings)?;
} else if components.format == SsTableFormat::Big {
check_uncompressed_crc_db(dir, &components, &mut findings).await;
}
check_statistics(dir, &components, platform.clone(), &mut findings).await;
if components.format == SsTableFormat::Big {
check_summary(dir, &components, platform.clone(), &mut findings).await;
}
if components.format == SsTableFormat::Big {
check_filter_false_negatives(dir, &components, platform.clone(), &mut findings).await;
}
let compression_metadata_corrupt = findings.iter().any(|f| {
matches!(
f.class,
VerifyErrorClass::CompressionInfoCorrupt | VerifyErrorClass::ChunkOffsetOutOfBounds
)
});
if !compression_metadata_corrupt {
let platform_for_order = platform.clone();
match full_row_scan_partitions(&components.data_path, config, platform).await {
Ok((rows, scan_partitions)) => {
rows_scanned = Some(rows);
if let Some(leaves) = bti_leaves {
if let Some(detail) =
bti_partition_identity_mismatch(&leaves, &scan_partitions)
{
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiRootPointerCorrupt,
"Partitions.db",
detail,
));
}
}
}
Err(e) => findings.push(classify_scan_error(&components, &e)),
}
check_key_order_and_ldt(
&components.data_path,
config,
platform_for_order,
&mut findings,
)
.await;
} }
Ok(VerifyReport {
directory: dir.to_path_buf(),
base_name: components.base_name,
format: components.format,
mode,
findings,
toc_components,
rows_scanned,
})
}
fn read_dir_files(dir: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
let entries = std::fs::read_dir(dir).map_err(|e| {
Error::invalid_path(format!("Cannot read SSTable dir {}: {}", dir.display(), e))
})?;
let mut data_files: Vec<PathBuf> = Vec::new();
let mut all_files: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
if !p.is_file() {
continue;
}
all_files.push(p.clone());
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.ends_with("-Data.db") {
data_files.push(p);
}
}
}
Ok((all_files, data_files))
}
fn resolve_components(dir: &Path) -> Result<ComponentSet> {
let (all_files, mut data_files) = read_dir_files(dir)?;
data_files.sort();
let data_path = data_files.into_iter().next().ok_or_else(|| {
Error::not_found(format!(
"No *-Data.db component found in SSTable directory {}",
dir.display()
))
})?;
build_component_set(&all_files, data_path)
}
fn resolve_components_for_data_path(dir: &Path, data_path: &Path) -> Result<ComponentSet> {
if !data_path.is_file() {
return Err(Error::not_found(format!(
"SSTable Data.db component not found at {}",
data_path.display()
)));
}
let (all_files, _data_files) = read_dir_files(dir)?;
build_component_set(&all_files, data_path.to_path_buf())
}
fn build_component_set(all_files: &[PathBuf], data_path: PathBuf) -> Result<ComponentSet> {
let data_name = data_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::invalid_path("Data.db filename is not valid UTF-8"))?;
let base_name = data_name
.strip_suffix("-Data.db")
.map(str::to_string)
.or_else(|| extract_sstable_base_name(&data_path))
.unwrap_or_else(|| {
data_name
.strip_suffix(".db")
.unwrap_or(data_name)
.to_string()
});
let format = SsTableDescriptor::parse_filename(data_name)
.map(|d| d.format)
.unwrap_or(SsTableFormat::Big);
let prefix = format!("{}-", base_name);
let mut present = BTreeMap::new();
for p in all_files {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if let Some(component) = name.strip_prefix(&prefix) {
present.insert(component.to_string(), p.clone());
}
}
}
Ok(ComponentSet {
base_name,
format,
present,
data_path,
})
}
fn is_real_component(component: &str) -> bool {
matches!(
component,
"TOC.txt" | "Digest.crc32" | "Digest.adler32" | "Digest.sha1" | "CRC.db"
) || (component.ends_with(".db") && !component.contains(".db."))
}
fn check_toc_and_presence(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) -> Result<Vec<String>> {
if !components.data_path.exists() {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!(
"required Data.db not found at {}",
components.data_path.display()
),
));
}
let toc_path = components.path(dir, "TOC.txt");
if !toc_path.exists() {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"TOC.txt",
format!("TOC.txt not present at {}", toc_path.display()),
));
return Ok(Vec::new());
}
let toc_raw = std::fs::read_to_string(&toc_path).map_err(|e| {
Error::corruption(format!(
"Cannot read TOC.txt at {}: {}",
toc_path.display(),
e
))
})?;
let mut listed = Vec::new();
for line in toc_raw.lines() {
let component = line.trim();
if component.is_empty() {
continue;
}
listed.push(component.to_string());
if !components.has(component) {
let expected = components.path(dir, component);
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
component.to_string(),
format!(
"TOC.txt lists component '{}' but '{}' is absent on disk",
component,
expected.display()
),
));
}
}
for present in components.present.keys() {
if !is_real_component(present) {
continue;
}
if !listed.iter().any(|c| c == present) {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
present.clone(),
format!(
"component '{}' is present on disk but not listed in TOC.txt (incomplete/corrupt TOC)",
present
),
));
}
}
Ok(listed)
}
fn check_digest(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) -> Result<()> {
let digest_path = components.path(dir, "Digest.crc32");
if !digest_path.exists() {
return Ok(());
}
let digest_text = std::fs::read_to_string(&digest_path).map_err(|e| {
Error::corruption(format!(
"Cannot read Digest.crc32 at {}: {}",
digest_path.display(),
e
))
})?;
let recorded: u32 = match digest_text.trim().parse::<u32>() {
Ok(v) => v,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::DigestMismatch,
"Digest.crc32",
format!(
"Digest.crc32 is not a valid integer ('{}'): {}",
digest_text.trim(),
e
),
));
return Ok(());
}
};
let data = match std::fs::read(&components.data_path) {
Ok(d) => d,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!("cannot read Data.db for digest check: {}", e),
));
return Ok(());
}
};
let computed = crc32fast::hash(&data);
if computed != recorded {
findings.push(VerifyFinding::new(
VerifyErrorClass::DigestMismatch,
"Digest.crc32",
format!(
"Digest.crc32 mismatch: recorded={} (0x{:08x}), computed={} (0x{:08x}) over {} bytes of Data.db",
recorded, recorded, computed, computed, data.len()
),
));
}
Ok(())
}
fn check_compression_info(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) -> Result<Option<CompressionInfo>> {
let ci_path = components.path(dir, "CompressionInfo.db");
if !ci_path.exists() {
return Ok(None); }
let bytes = std::fs::read(&ci_path).map_err(|e| {
Error::corruption(format!(
"Cannot read CompressionInfo.db at {}: {}",
ci_path.display(),
e
))
})?;
let info = match CompressionInfo::parse(&bytes) {
Ok(info) => info,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::CompressionInfoCorrupt,
"CompressionInfo.db",
format!("CompressionInfo.db failed to parse: {}", e),
));
return Ok(None);
}
};
let data_len = match std::fs::metadata(&components.data_path) {
Ok(m) => m.len(),
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!("cannot stat Data.db for chunk-bounds check: {}", e),
));
return Ok(Some(info));
}
};
let mut offset_out_of_bounds = false;
for (i, &offset) in info.chunk_offsets.iter().enumerate() {
if offset.saturating_add(4) > data_len {
offset_out_of_bounds = true;
findings.push(VerifyFinding::new(
VerifyErrorClass::ChunkOffsetOutOfBounds,
"CompressionInfo.db",
format!(
"chunk[{}] offset {} (0x{:x}) points past Data.db end ({} bytes)",
i, offset, offset, data_len
),
));
}
}
if offset_out_of_bounds {
return Ok(None);
}
Ok(Some(info))
}
struct BtiResolvedLeaf {
prefix: Vec<u8>,
inline_raw_key: Option<Vec<u8>>,
data_position: u64,
}
fn check_bti_structure(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) -> Result<Option<Vec<BtiResolvedLeaf>>> {
use crate::storage::sstable::bti::parser::{
iterate_partitions_in_bti_file, iterate_rows_for_partition, resolve_rows_db_entry,
BtiPartitionLocation,
};
use std::io::Cursor;
let partitions_path = components.path(dir, "Partitions.db");
let partitions_bytes = match std::fs::read(&partitions_path) {
Ok(b) => b,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Partitions.db",
format!("cannot read Partitions.db: {}", e),
));
return Ok(None);
}
};
if partitions_bytes.len() < 8 {
findings.push(VerifyFinding::new(
VerifyErrorClass::UnexpectedEof,
"Partitions.db",
format!(
"Partitions.db is {} bytes — shorter than the mandatory 8-byte trie root footer (truncated)",
partitions_bytes.len()
),
));
return Ok(None);
}
let mut cursor = Cursor::new(&partitions_bytes);
let partitions = match iterate_partitions_in_bti_file(&mut cursor) {
Ok(p) => p,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiRootPointerCorrupt,
"Partitions.db",
format!(
"Partitions.db trie walk failed (corrupt root pointer / node): {}",
e
),
));
return Ok(None);
}
};
if partitions.is_empty() {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiRootPointerCorrupt,
"Partitions.db",
format!(
"Partitions.db ({} bytes) yielded zero partition keys — the root pointer is corrupt",
partitions_bytes.len()
),
));
return Ok(None);
}
let rows_path = components.path(dir, "Rows.db");
let rows_bytes = match std::fs::read(&rows_path) {
Ok(b) => b,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Rows.db",
format!("cannot read Rows.db: {}", e),
));
let leaves = partitions
.into_iter()
.filter_map(|(prefix, location)| match location {
BtiPartitionLocation::DataOffset(off) => Some(BtiResolvedLeaf {
prefix,
inline_raw_key: None,
data_position: off,
}),
BtiPartitionLocation::RowsOffset(_) => None,
})
.collect();
return Ok(Some(leaves));
}
};
let mut leaves: Vec<BtiResolvedLeaf> = Vec::with_capacity(partitions.len());
for (prefix, location) in partitions {
match location {
BtiPartitionLocation::RowsOffset(off) => {
let off = off as usize;
if off + 2 > rows_bytes.len() {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiTrieCorrupt,
"Rows.db",
format!(
"partition (trie prefix {} bytes) references Rows.db offset {} which is past EOF ({} bytes) — Rows.db is truncated/corrupt",
prefix.len(),
off,
rows_bytes.len()
),
));
continue;
}
if let Err(e) = iterate_rows_for_partition(&rows_bytes, off) {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiTrieCorrupt,
"Rows.db",
format!(
"row-index trie for partition at Rows.db offset {} failed to parse (truncated/corrupt): {}",
off, e
),
));
continue;
}
let key_length =
u16::from_be_bytes([rows_bytes[off], rows_bytes[off + 1]]) as usize;
let key_start = off + 2;
let key_end = key_start + key_length;
if key_end > rows_bytes.len() {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiTrieCorrupt,
"Rows.db",
format!(
"Rows.db entry at offset {} declares an inline key length {} that overruns the file ({} bytes)",
off, key_length, rows_bytes.len()
),
));
continue;
}
let inline_raw_key = rows_bytes[key_start..key_end].to_vec();
let data_position = match resolve_rows_db_entry(&rows_bytes, off) {
Ok(hdr) => hdr.data_position,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::BtiTrieCorrupt,
"Rows.db",
format!(
"Rows.db entry at offset {} failed to deserialize (truncated/corrupt): {}",
off, e
),
));
continue;
}
};
leaves.push(BtiResolvedLeaf {
prefix,
inline_raw_key: Some(inline_raw_key),
data_position,
});
}
BtiPartitionLocation::DataOffset(off) => {
leaves.push(BtiResolvedLeaf {
prefix,
inline_raw_key: None,
data_position: off,
});
}
}
}
Ok(Some(leaves))
}
fn check_big_index(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) -> Result<()> {
use crate::storage::sstable::index_reader::parse_big_index_entry;
let index_path = components.path(dir, "Index.db");
if !index_path.exists() {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Index.db",
format!(
"BIG-format Index.db not present at {}",
index_path.display()
),
));
return Ok(());
}
let bytes = std::fs::read(&index_path).map_err(|e| {
Error::corruption(format!(
"Cannot read Index.db at {}: {}",
index_path.display(),
e
))
})?;
if bytes.is_empty() {
findings.push(VerifyFinding::new(
VerifyErrorClass::IndexEntryCorrupt,
"Index.db",
"Index.db is empty (no partition entries)".to_string(),
));
return Ok(());
}
let total = bytes.len();
let mut remaining: &[u8] = &bytes;
let mut entry_index = 0usize;
loop {
if remaining.is_empty() {
break;
}
let consumed_before = total - remaining.len();
match parse_big_index_entry(remaining) {
Ok((rest, _entry)) => {
if rest.len() >= remaining.len() {
findings.push(VerifyFinding::new(
VerifyErrorClass::IndexEntryCorrupt,
"Index.db",
format!(
"Index.db entry {} at byte offset {} made no forward progress (corrupt length field)",
entry_index, consumed_before
),
));
return Ok(());
}
remaining = rest;
entry_index += 1;
}
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::IndexEntryCorrupt,
"Index.db",
format!(
"Index.db entry {} at byte offset {} failed to parse ({} of {} bytes consumed): {:?}",
entry_index, consumed_before, consumed_before, total, e
),
));
return Ok(());
}
}
}
if entry_index == 0 {
findings.push(VerifyFinding::new(
VerifyErrorClass::IndexEntryCorrupt,
"Index.db",
format!(
"Index.db parsed zero partition entries from {} bytes",
total
),
));
}
Ok(())
}
async fn check_summary(
dir: &Path,
components: &ComponentSet,
platform: Arc<Platform>,
findings: &mut Vec<VerifyFinding>,
) {
use crate::storage::sstable::summary_reader::SummaryReader;
let summary_path = components.path(dir, "Summary.db");
if !summary_path.exists() {
return; }
if let Err(e) = SummaryReader::open(&summary_path, platform).await {
findings.push(VerifyFinding::new(
VerifyErrorClass::SummaryCorrupt,
"Summary.db",
format!("Summary.db failed to parse: {}", e),
));
}
}
async fn check_filter_false_negatives(
dir: &Path,
components: &ComponentSet,
platform: Arc<Platform>,
findings: &mut Vec<VerifyFinding>,
) {
use crate::storage::sstable::bloom::BloomFilter;
use crate::storage::sstable::index_reader::IndexReader;
let filter_path = components.path(dir, "Filter.db");
let index_path = components.path(dir, "Index.db");
if !filter_path.exists() || !index_path.exists() {
return;
}
let Ok(filter_bytes) = std::fs::read(&filter_path) else {
return;
};
let Ok(bloom) = BloomFilter::deserialize(&filter_bytes) else {
return;
};
let Ok(reader) = IndexReader::open(&index_path, platform).await else {
return;
};
let mut present = 0usize;
let mut false_negatives = 0usize;
for entry in reader.get_partition_entries() {
present += 1;
if !bloom.might_contain(&entry.key_digest) {
false_negatives += 1;
}
}
if false_negatives > 0 {
findings.push(VerifyFinding::new(
VerifyErrorClass::FilterFalseNegative,
"Filter.db",
format!(
"Bloom filter reported {false_negatives} false negative(s) over {present} present \
partition key(s): a present key hashes to a bit the filter reports unset, so the \
BIG point-lookup path would return no rows for a live partition (silent data \
invisibility). Filter.db carries no checksum, so a 1→0 bit flip inside the bit \
array is not caught on load; full scans and BTI lookups are unaffected."
),
));
}
}
fn check_inline_chunk_crc(
components: &ComponentSet,
info: &CompressionInfo,
findings: &mut Vec<VerifyFinding>,
) -> Result<()> {
use crate::storage::sstable::chunk_reader::ChunkReader;
use std::fs::File;
let file = match File::open(&components.data_path) {
Ok(f) => f,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!("cannot open Data.db for chunk-CRC check: {}", e),
));
return Ok(());
}
};
let total_size = match file.metadata() {
Ok(m) => m.len(),
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!("cannot stat Data.db for chunk-CRC check: {}", e),
));
return Ok(());
}
};
let reader = std::io::BufReader::new(file);
let mut chunk_reader = ChunkReader::new(reader, info.clone(), total_size);
if let Err(e) = chunk_reader.read_all_chunks() {
findings.push(classify_data_error("Data.db", &e));
}
Ok(())
}
async fn check_uncompressed_crc_db(
dir: &Path,
components: &ComponentSet,
findings: &mut Vec<VerifyFinding>,
) {
use crate::storage::sstable::reader::crc::CrcDb;
use tokio::io::AsyncReadExt;
let crc_path = components.path(dir, "CRC.db");
if !crc_path.exists() {
return;
}
let data_len = tokio::fs::metadata(&components.data_path)
.await
.map(|m| m.len())
.unwrap_or(0);
let crc = match CrcDb::open(&crc_path, data_len).await {
Ok(c) => c,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::UncompressedChunkCrcMismatch,
"CRC.db",
format!("CRC.db failed to parse: {e}"),
));
return;
}
};
let chunk_size = crc.chunk_size() as usize;
let mut file = match tokio::fs::File::open(&components.data_path).await {
Ok(f) => f,
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::MissingComponent,
"Data.db",
format!("cannot open Data.db for CRC.db check: {e}"),
));
return;
}
};
let mut chunk_index = 0usize;
let mut offset: u64 = 0;
let mut buf = vec![0u8; chunk_size];
loop {
let mut filled = 0usize;
loop {
match file.read(&mut buf[filled..]).await {
Ok(0) => break,
Ok(n) => {
filled += n;
if filled == chunk_size {
break;
}
}
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::UncompressedChunkCrcMismatch,
"Data.db",
format!("read error verifying chunk {chunk_index} against CRC.db: {e}"),
));
return;
}
}
}
if filled == 0 {
break; }
let computed = crc32fast::hash(&buf[..filled]);
match crc.crc_for_chunk(chunk_index) {
Ok(expected) => {
if computed != expected {
findings.push(VerifyFinding::new(
VerifyErrorClass::UncompressedChunkCrcMismatch,
"Data.db",
format!(
"uncompressed CRC32 mismatch for chunk {chunk_index} at Data.db offset 0x{offset:x} ({filled} bytes): expected=0x{expected:08x} (CRC.db), computed=0x{computed:08x}"
),
));
return;
}
}
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::UncompressedChunkCrcMismatch,
"CRC.db",
format!("CRC.db has no entry for Data.db chunk {chunk_index} (truncated): {e}"),
));
return;
}
}
offset += filled as u64;
chunk_index += 1;
if filled < chunk_size {
break; }
}
}
async fn check_statistics(
dir: &Path,
components: &ComponentSet,
platform: Arc<Platform>,
findings: &mut Vec<VerifyFinding>,
) {
use crate::storage::sstable::statistics_reader::StatisticsReader;
let stats_path = components.path(dir, "Statistics.db");
if !stats_path.exists() {
return; }
match std::fs::read(&stats_path) {
Ok(bytes) if bytes.len() >= 8 => {
let num_components = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if num_components == 0 || num_components > 100 {
findings.push(VerifyFinding::new(
VerifyErrorClass::StatisticsHeaderCorrupt,
"Statistics.db",
format!(
"Statistics.db TOC header is corrupt: num_components={} at byte 0 (expected 1..=100; first 4 bytes {:02x} {:02x} {:02x} {:02x})",
num_components, bytes[0], bytes[1], bytes[2], bytes[3]
),
));
return;
}
}
Ok(bytes) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::StatisticsHeaderCorrupt,
"Statistics.db",
format!(
"Statistics.db is {} bytes — too small for the 8-byte TOC header",
bytes.len()
),
));
return;
}
Err(e) => {
findings.push(VerifyFinding::new(
VerifyErrorClass::StatisticsHeaderCorrupt,
"Statistics.db",
format!("cannot read Statistics.db: {}", e),
));
return;
}
}
if let Err(e) = StatisticsReader::open(&stats_path, platform).await {
findings.push(VerifyFinding::new(
VerifyErrorClass::StatisticsHeaderCorrupt,
"Statistics.db",
format!("Statistics.db failed to parse: {}", e),
));
}
}
async fn full_row_scan_partitions(
data_path: &Path,
config: &Config,
platform: Arc<Platform>,
) -> Result<(usize, Vec<(u64, Vec<u8>)>)> {
let reader = SSTableReader::open(data_path, config, platform).await?;
let entries = reader.get_all_entries().await?;
let rows = entries.len();
let partitions = reader.distinct_partition_keys_with_positions().await?;
Ok((rows, partitions))
}
fn bti_partition_identity_mismatch(
leaves: &[BtiResolvedLeaf],
data_partitions: &[(u64, Vec<u8>)],
) -> Option<String> {
use crate::storage::sstable::bti::parser::encode_partition_key_for_bti_trie;
use std::collections::HashMap;
let hex = |b: &[u8]| b.iter().map(|x| format!("{:02x}", x)).collect::<String>();
let pos_to_key: HashMap<u64, &Vec<u8>> = data_partitions.iter().map(|(p, k)| (*p, k)).collect();
let mut leaf_keys: Vec<Vec<u8>> = Vec::with_capacity(leaves.len());
for leaf in leaves {
let raw_key = match &leaf.inline_raw_key {
Some(inline) => match pos_to_key.get(&leaf.data_position) {
Some(by_pos) => {
if by_pos.as_slice() != inline.as_slice() {
return Some(format!(
"Partitions.db leaf (prefix {}) inline raw key {} disagrees with the key at its Data.db position {} ({}) — the leaf payload was tampered",
hex(&leaf.prefix),
hex(inline),
leaf.data_position,
hex(by_pos),
));
}
inline.clone()
}
None => {
return Some(format!(
"Partitions.db leaf (prefix {}) inline raw key {} records Data.db position {} which is not a decoded partition start — the Rows.db entry's data position is corrupt (a BTI read would seek to the wrong partition)",
hex(&leaf.prefix),
hex(inline),
leaf.data_position,
));
}
},
None => match pos_to_key.get(&leaf.data_position) {
Some(k) => (*k).clone(),
None => {
return Some(format!(
"Partitions.db leaf (prefix {}) payload points at Data.db position {} which is not a decoded partition start — the leaf payload is corrupt (same prefix, wrong partition)",
hex(&leaf.prefix),
leaf.data_position,
));
}
},
};
let encoded = encode_partition_key_for_bti_trie(&raw_key);
if !encoded.starts_with(leaf.prefix.as_slice()) {
return Some(format!(
"Partitions.db leaf prefix {} is inconsistent with its payload's partition key (encodes to {}) — the trie path does not match the leaf payload",
hex(&leaf.prefix),
hex(&encoded),
));
}
leaf_keys.push(raw_key);
}
let mut data_counts: HashMap<&[u8], i64> = HashMap::new();
for (_, k) in data_partitions {
*data_counts.entry(k.as_slice()).or_insert(0) += 1;
}
let mut leaf_counts: HashMap<&[u8], i64> = HashMap::new();
for k in &leaf_keys {
*leaf_counts.entry(k.as_slice()).or_insert(0) += 1;
}
if leaf_keys.len() != data_partitions.len() {
return Some(format!(
"Partitions.db trie yielded {} partition keys but Data.db decoded {} distinct partitions — the trie was walked from a corrupt root",
leaf_keys.len(),
data_partitions.len()
));
}
for (k, &lc) in &leaf_counts {
let dc = data_counts.get(k).copied().unwrap_or(0);
if lc != dc {
return Some(format!(
"Partitions.db resolves partition key {} {} time(s) but Data.db decodes it {} time(s) — the trie does not match Data.db identities (same count, different keys)",
hex(k),
lc,
dc,
));
}
}
for (k, &dc) in &data_counts {
let lc = leaf_counts.get(k).copied().unwrap_or(0);
if lc != dc {
return Some(format!(
"Data.db partition key {} appears {} time(s) but Partitions.db resolves it {} time(s) — the trie does not match Data.db identities",
hex(k),
dc,
lc,
));
}
}
None
}
async fn check_key_order_and_ldt(
data_path: &Path,
config: &Config,
platform: Arc<Platform>,
findings: &mut Vec<VerifyFinding>,
) {
let reader = match SSTableReader::open(data_path, config, platform).await {
Ok(r) => r,
Err(_) => {
return;
}
};
let signed_ldt = !reader.has_uint_deletion_time();
let partitions = match reader.partition_verify_scan().await {
Ok(p) => p,
Err(_) => {
return;
}
};
findings.extend(classify_order_and_ldt(&partitions, signed_ldt));
if let Some(schema) = reader.effective_schema() {
if !schema.clustering_keys.is_empty() {
if let Ok(partition_rows) = reader.partition_clustering_verify_scan().await {
findings.extend(classify_clustering_row_order(&partition_rows, &schema));
}
}
}
}
fn compare_clustering_tuples(
a: &[crate::types::Value],
b: &[crate::types::Value],
schema: &crate::schema::TableSchema,
) -> Result<std::cmp::Ordering> {
use crate::types::Value;
use std::cmp::Ordering;
let comparators = schema.get_clustering_key_comparators()?;
for (i, ck) in schema.clustering_keys.iter().enumerate() {
let av = a.get(i).unwrap_or(&Value::Null);
let bv = b.get(i).unwrap_or(&Value::Null);
let ord = match (av, bv) {
(Value::Null, Value::Null) => Ordering::Equal,
(Value::Null, _) => Ordering::Less,
(_, Value::Null) => Ordering::Greater,
(_, _) => {
let cmp = comparators
.get(i)
.ok_or_else(|| {
Error::Schema(format!(
"missing clustering comparator for column {}",
ck.name
))
})?
.compare(av, bv)?;
if ck.order == crate::schema::ClusteringOrder::Desc {
cmp.reverse()
} else {
cmp
}
}
};
if ord != Ordering::Equal {
return Ok(ord);
}
}
Ok(Ordering::Equal)
}
fn classify_clustering_row_order(
partition_rows: &[(usize, Vec<Vec<crate::types::Value>>)],
schema: &crate::schema::TableSchema,
) -> Vec<VerifyFinding> {
use std::cmp::Ordering;
let mut findings = Vec::new();
for (part_idx, rows) in partition_rows {
for pair in rows.windows(2) {
let (prev, cur) = (&pair[0], &pair[1]);
let ord = match compare_clustering_tuples(cur, prev, schema) {
Ok(o) => o,
Err(_) => continue,
};
if ord != Ordering::Greater {
findings.push(VerifyFinding::new(
VerifyErrorClass::OutOfOrderKeyOrRow,
"Data.db",
format!(
"partition {} has an out-of-order clustering row: {:?} is not strictly after the previous row {:?} in schema clustering order",
part_idx, cur, prev,
),
));
break;
}
}
}
findings
}
fn classify_order_and_ldt(
partitions: &[(Vec<u8>, Option<i32>)],
signed_ldt: bool,
) -> Vec<VerifyFinding> {
use crate::util::cassandra_murmur3::cassandra_murmur3_token;
let mut findings = Vec::new();
let hex = |b: &[u8]| b.iter().map(|x| format!("{:02x}", x)).collect::<String>();
let mut prev: Option<(i64, Vec<u8>)> = None;
for (idx, (key, _ldt)) in partitions.iter().enumerate() {
let token = cassandra_murmur3_token(key);
if let Some((prev_token, prev_key)) = prev.as_ref() {
let ordered = (*prev_token, prev_key.as_slice()) < (token, key.as_slice());
if !ordered {
findings.push(VerifyFinding::new(
VerifyErrorClass::OutOfOrderKeyOrRow,
"Data.db",
format!(
"partition {} (key {}, token {}) is not strictly after the previous partition (key {}, token {}) — partitions are stored out of Murmur3 token order",
idx,
hex(key),
token,
hex(prev_key),
prev_token,
),
));
break;
}
}
prev = Some((token, key.clone()));
}
if signed_ldt {
for (key, ldt) in partitions {
if let Some(ldt) = ldt {
if *ldt < 0 {
findings.push(VerifyFinding::new(
VerifyErrorClass::InvalidLocalDeletionTime,
"Data.db",
format!(
"partition (key {}) has a negative localDeletionTime {} (0x{:08x}) on the signed (nb) DeletionTime form — a valid deletion time is >= 0 seconds since epoch",
hex(key),
ldt,
*ldt as u32,
),
));
break;
}
}
}
}
findings
}
fn classify_data_error(component: &str, err: &Error) -> VerifyFinding {
let msg = err.to_string();
let class = if msg.contains("Failed to read")
|| msg.contains("failed to fill whole buffer")
|| msg.contains("UnexpectedEof")
|| msg.contains("end of file")
{
VerifyErrorClass::UnexpectedEof
} else {
VerifyErrorClass::ChunkDecompressionError
};
VerifyFinding::new(class, component.to_string(), msg)
}
fn classify_scan_error(components: &ComponentSet, err: &Error) -> VerifyFinding {
let _ = components; let class = classify_scan_error_class(err);
VerifyFinding::new(class, "Data.db".to_string(), err.to_string())
}
fn classify_scan_error_class(err: &Error) -> VerifyErrorClass {
let msg = err.to_string();
let lower = msg.to_lowercase();
if matches!(err, Error::UnsupportedFormat(_))
&& (lower.contains("compress") || lower.contains("compiled in"))
{
return VerifyErrorClass::UnsupportedCompressionFeature;
}
if msg.contains("CRC32 mismatch") {
VerifyErrorClass::ChunkDecompressionError
} else if msg.contains("failed to fill whole buffer")
|| lower.contains("unexpected")
|| lower.contains("end of file")
|| msg.contains("too small")
{
VerifyErrorClass::UnexpectedEof
} else if msg.contains("decompress")
|| msg.contains("Decompressed")
|| msg.contains("length prefix")
{
VerifyErrorClass::ChunkDecompressionError
} else {
VerifyErrorClass::RowScanFailed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_class_codes_are_stable() {
assert_eq!(VerifyErrorClass::DigestMismatch.code(), "DigestMismatch");
assert_eq!(
VerifyErrorClass::ChunkOffsetOutOfBounds.code(),
"ChunkOffsetOutOfBounds"
);
assert_eq!(
VerifyErrorClass::BtiRootPointerCorrupt.code(),
"BtiRootPointerCorrupt"
);
assert_eq!(
VerifyErrorClass::OutOfOrderKeyOrRow.code(),
"OutOfOrderKeyOrRow"
);
assert_eq!(
VerifyErrorClass::InvalidLocalDeletionTime.code(),
"InvalidLocalDeletionTime"
);
assert_eq!(
VerifyErrorClass::UnsupportedCompressionFeature.code(),
"UnsupportedCompressionFeature"
);
}
#[test]
fn unsupported_compression_feature_classified_distinctly() {
let dict_err = Error::UnsupportedFormat(
"zstd dictionary compression (Dictionary_ID=1234) is unsupported for chunk 0 at offset 0x0"
.to_string(),
);
assert_eq!(
classify_scan_error_class(&dict_err),
VerifyErrorClass::UnsupportedCompressionFeature
);
assert_ne!(
classify_scan_error_class(&dict_err),
VerifyErrorClass::ChunkDecompressionError
);
assert_ne!(
classify_scan_error_class(&dict_err),
VerifyErrorClass::DigestMismatch
);
let decode_err = Error::InvalidFormat(
"Zstd decompression failed for chunk 0 at offset 0x0: corrupted input".to_string(),
);
assert_eq!(
classify_scan_error_class(&decode_err),
VerifyErrorClass::ChunkDecompressionError
);
let crc_err = Error::Corruption("Data.db chunk 0 CRC32 mismatch".to_string());
assert_eq!(
classify_scan_error_class(&crc_err),
VerifyErrorClass::ChunkDecompressionError
);
}
#[test]
fn non_compression_unsupported_format_falls_through_to_generic() {
let non_compression = Error::UnsupportedFormat(
"tuple element type not yet supported for chunk 0 at offset 0x0".to_string(),
);
assert_eq!(
classify_scan_error_class(&non_compression),
VerifyErrorClass::RowScanFailed
);
assert_ne!(
classify_scan_error_class(&non_compression),
VerifyErrorClass::UnsupportedCompressionFeature
);
let not_compiled = Error::UnsupportedFormat("Zstd support not compiled in".to_string());
assert_eq!(
classify_scan_error_class(¬_compiled),
VerifyErrorClass::UnsupportedCompressionFeature
);
let unknown_algo =
Error::UnsupportedFormat("Unknown compression algorithm: BogusCompressor".to_string());
assert_eq!(
classify_scan_error_class(&unknown_algo),
VerifyErrorClass::UnsupportedCompressionFeature
);
}
#[cfg(feature = "zstd")]
#[test]
fn dictionary_frame_wires_reader_error_to_unsupported_class() {
use crate::parser::header::CassandraVersion;
use crate::storage::sstable::chunk_decompressor::ChunkDecompressor;
use crate::storage::sstable::compression_info::CompressionInfo;
use std::io::Cursor;
let plaintext =
b"cqlite|zstd|dictionary|row=verify|table=zstd_dictionary_table|value=payload-7"
.to_vec();
let samples: Vec<Vec<u8>> = (0..1024u32)
.map(|i| format!("cqlite|zstd|dictionary|row={i}|value={}", i % 37).into_bytes())
.collect();
let dict = zstd::dict::from_samples(&samples, 4 * 1024).expect("train zstd dictionary");
let dict_frame = zstd::bulk::Compressor::with_dictionary(3, &dict)
.expect("dictionary compressor")
.compress(&plaintext)
.expect("dictionary-compress chunk");
let mut image = dict_frame.clone();
image.extend_from_slice(&crc32fast::hash(&dict_frame).to_be_bytes());
let info = CompressionInfo {
algorithm: "ZstdCompressor".to_string(),
option_pairs: vec![],
chunk_length: plaintext.len() as u32,
max_compressed_length: i32::MAX as u32,
data_length: plaintext.len() as u64,
chunk_offsets: vec![0],
};
let mut dec = ChunkDecompressor::new(info, CassandraVersion::V5_0Release)
.expect("build decompressor");
let err = dec
.decompress_chunk_by_index(&mut Cursor::new(image), 0)
.expect_err("dictionary frame must be rejected");
assert!(
matches!(err, Error::UnsupportedFormat(_)),
"reader must reject with UnsupportedFormat; got: {err}"
);
assert_eq!(
classify_scan_error_class(&err),
VerifyErrorClass::UnsupportedCompressionFeature,
"verify must classify the reader's dictionary rejection as \
UnsupportedCompressionFeature; got err: {err}"
);
}
#[test]
fn mode_labels() {
assert_eq!(VerifyMode::Quick.as_str(), "quick");
assert_eq!(VerifyMode::Full.as_str(), "full");
assert_ne!(VerifyMode::Quick, VerifyMode::Full);
}
#[test]
fn real_component_recognition_excludes_sidecars() {
assert!(is_real_component("Data.db"));
assert!(is_real_component("Statistics.db"));
assert!(is_real_component("CompressionInfo.db"));
assert!(is_real_component("TOC.txt"));
assert!(is_real_component("Digest.crc32"));
assert!(!is_real_component("Data.db.jsonl"));
assert!(!is_real_component("Statistics.db.txt"));
assert!(!is_real_component("CompressionInfo.db.txt"));
assert!(!is_real_component("README.md"));
}
#[test]
fn report_summary_line_distinguishes_ok_and_fail() {
let ok = VerifyReport {
directory: PathBuf::from("/x"),
base_name: "nb-1-big".to_string(),
format: SsTableFormat::Big,
mode: VerifyMode::Full,
findings: vec![],
toc_components: vec![],
rows_scanned: Some(3),
};
assert!(ok.is_ok());
assert!(ok.summary_line().contains("VERIFY OK"));
let fail = VerifyReport {
directory: PathBuf::from("/x"),
base_name: "nb-1-big".to_string(),
format: SsTableFormat::Big,
mode: VerifyMode::Full,
findings: vec![VerifyFinding::new(
VerifyErrorClass::DigestMismatch,
"Digest.crc32",
"boom",
)],
toc_components: vec![],
rows_scanned: None,
};
assert!(!fail.is_ok());
assert_eq!(fail.primary_class(), Some(VerifyErrorClass::DigestMismatch));
assert!(fail.summary_line().contains("VERIFY FAIL"));
assert!(fail.summary_line().contains("DigestMismatch"));
}
use crate::storage::sstable::bti::parser::encode_partition_key_for_bti_trie;
fn trie_key_prefix(raw: &[u8], prefix_len: usize) -> Vec<u8> {
encode_partition_key_for_bti_trie(raw)[..prefix_len].to_vec()
}
fn inline_leaf(raw: &[u8], data_position: u64) -> BtiResolvedLeaf {
BtiResolvedLeaf {
prefix: trie_key_prefix(raw, 2),
inline_raw_key: Some(raw.to_vec()),
data_position,
}
}
fn data_offset_leaf(prefix_from: &[u8], data_position: u64) -> BtiResolvedLeaf {
BtiResolvedLeaf {
prefix: trie_key_prefix(prefix_from, 2),
inline_raw_key: None,
data_position,
}
}
fn data_partitions(keys: &[Vec<u8>]) -> Vec<(u64, Vec<u8>)> {
keys.iter()
.enumerate()
.map(|(i, k)| (i as u64 * 100, k.clone()))
.collect()
}
#[test]
fn identity_check_passes_for_inline_rows_leaves() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let leaves: Vec<BtiResolvedLeaf> =
data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
assert_eq!(bti_partition_identity_mismatch(&leaves, &data), None);
}
#[test]
fn identity_check_passes_for_data_offset_leaves() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let leaves: Vec<BtiResolvedLeaf> = data
.iter()
.map(|(pos, k)| data_offset_leaf(k, *pos))
.collect();
assert_eq!(bti_partition_identity_mismatch(&leaves, &data), None);
}
#[test]
fn identity_check_detects_inline_payload_pointing_at_wrong_partition() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let mut leaves: Vec<BtiResolvedLeaf> =
data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
leaves[0].inline_raw_key = Some(99u32.to_be_bytes().to_vec());
assert!(
bti_partition_identity_mismatch(&leaves, &data).is_some(),
"an inline payload pointing at a partition absent from Data.db must be flagged"
);
}
#[test]
fn identity_check_detects_data_offset_payload_pointing_at_wrong_partition() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let mut leaves: Vec<BtiResolvedLeaf> = data
.iter()
.map(|(pos, k)| data_offset_leaf(k, *pos))
.collect();
leaves[0].data_position = data[1].0;
let detail = bti_partition_identity_mismatch(&leaves, &data)
.expect("a DataOffset payload pointing at the wrong partition must be flagged");
assert!(
detail.contains("inconsistent") || detail.contains("identities"),
"unexpected detail: {detail}"
);
}
#[test]
fn identity_check_detects_data_offset_payload_pointing_at_non_partition() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let mut leaves: Vec<BtiResolvedLeaf> = data
.iter()
.map(|(pos, k)| data_offset_leaf(k, *pos))
.collect();
leaves[0].data_position = 37; let detail = bti_partition_identity_mismatch(&leaves, &data)
.expect("a DataOffset pointing at a non-partition position must be flagged");
assert!(detail.contains("not a decoded partition start"));
}
#[test]
fn identity_check_detects_same_count_wrong_keys_via_multiset() {
let data_keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&data_keys);
let leaves: Vec<BtiResolvedLeaf> =
(0..3).map(|_| inline_leaf(&data[0].1, data[0].0)).collect();
let detail = bti_partition_identity_mismatch(&leaves, &data)
.expect("same-count wrong-identity must be flagged");
assert!(
detail.contains("identities") || detail.contains("time(s)"),
"expected a multiset-identity mismatch, got: {detail}"
);
}
#[test]
fn identity_check_detects_inline_leaf_with_corrupt_data_position() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let mut leaves: Vec<BtiResolvedLeaf> =
data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
leaves[0].data_position = 9999; let detail = bti_partition_identity_mismatch(&leaves, &data).expect(
"an inline leaf with a valid key but a non-partition data position must be flagged",
);
assert!(detail.contains("not a decoded partition start"));
}
#[test]
fn identity_check_detects_one_swapped_key() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let mut leaves: Vec<BtiResolvedLeaf> =
data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
leaves[0] = inline_leaf(&99u32.to_be_bytes(), 10_000);
assert!(bti_partition_identity_mismatch(&leaves, &data).is_some());
}
use crate::util::cassandra_murmur3::cassandra_murmur3_token;
fn ordered_partitions(keys: &[Vec<u8>]) -> Vec<(Vec<u8>, Option<i32>)> {
let mut v: Vec<Vec<u8>> = keys.to_vec();
v.sort_by_key(|k| (cassandra_murmur3_token(k), k.clone()));
v.into_iter().map(|k| (k, None)).collect()
}
#[test]
fn order_ldt_clean_partitions_produce_no_findings() {
let keys: Vec<Vec<u8>> = (1u32..=6).map(|i| i.to_be_bytes().to_vec()).collect();
let partitions = ordered_partitions(&keys);
assert!(
classify_order_and_ldt(&partitions, true).is_empty(),
"in-token-order partitions with live LDT must produce zero findings"
);
}
#[test]
fn order_ldt_detects_out_of_order_partition_keys() {
let keys: Vec<Vec<u8>> = (1u32..=6).map(|i| i.to_be_bytes().to_vec()).collect();
let mut partitions = ordered_partitions(&keys);
partitions.swap(0, 1);
let findings = classify_order_and_ldt(&partitions, true);
assert!(
findings
.iter()
.any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
"swapping two partitions must be flagged OutOfOrderKeyOrRow, got {:?}",
findings
);
}
#[test]
fn order_ldt_detects_duplicate_partition_token_as_out_of_order() {
let k = 7u32.to_be_bytes().to_vec();
let partitions = vec![(k.clone(), None), (k, None)];
let findings = classify_order_and_ldt(&partitions, true);
assert!(findings
.iter()
.any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow));
}
#[test]
fn order_ldt_flags_negative_ldt_on_signed_nb_form() {
let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
partitions[0].1 = Some(-1);
let findings = classify_order_and_ldt(&partitions, true);
assert!(
findings
.iter()
.any(|f| f.class == VerifyErrorClass::InvalidLocalDeletionTime),
"negative nb localDeletionTime must be flagged, got {:?}",
findings
);
}
#[test]
fn order_ldt_does_not_flag_far_future_ldt_on_unsigned_oa_form() {
let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
partitions[0].1 = Some(-1); let findings = classify_order_and_ldt(&partitions, false);
assert!(
!findings
.iter()
.any(|f| f.class == VerifyErrorClass::InvalidLocalDeletionTime),
"far-future unsigned oa/da LDT must NOT be flagged, got {:?}",
findings
);
}
#[test]
fn order_ldt_positive_deletion_time_is_clean() {
let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
partitions[0].1 = Some(1_700_000_000); assert!(classify_order_and_ldt(&partitions, true).is_empty());
assert!(classify_order_and_ldt(&partitions, false).is_empty());
}
use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
use crate::types::Value;
use std::collections::HashMap;
fn schema_one_ck(order: ClusteringOrder) -> TableSchema {
TableSchema {
keyspace: "issue_1282".to_string(),
table: "tbl".to_string(),
partition_keys: vec![KeyColumn {
name: "pk".to_string(),
data_type: "int".to_string(),
position: 0,
}],
clustering_keys: vec![ClusteringColumn {
name: "ck".to_string(),
data_type: "int".to_string(),
position: 0,
order,
}],
columns: vec![Column {
name: "v".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_static: false,
}],
comments: HashMap::new(),
dropped_columns: HashMap::new(),
}
}
fn ck_int(n: i32) -> Vec<Value> {
vec![Value::Integer(n)]
}
#[test]
fn clustering_order_ascending_rows_are_clean() {
let schema = schema_one_ck(ClusteringOrder::Asc);
let partitions = vec![(0usize, vec![ck_int(1), ck_int(2), ck_int(3)])];
assert!(
classify_clustering_row_order(&partitions, &schema).is_empty(),
"in-order ASC clustering rows must produce no findings"
);
}
#[test]
fn clustering_order_out_of_order_row_is_flagged() {
let schema = schema_one_ck(ClusteringOrder::Asc);
let partitions = vec![(0usize, vec![ck_int(1), ck_int(3), ck_int(2)])];
let findings = classify_clustering_row_order(&partitions, &schema);
assert!(
findings
.iter()
.any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
"an out-of-order clustering row must be flagged OutOfOrderKeyOrRow, got {:?}",
findings
);
}
#[test]
fn clustering_order_duplicate_row_is_flagged() {
let schema = schema_one_ck(ClusteringOrder::Asc);
let partitions = vec![(0usize, vec![ck_int(5), ck_int(5)])];
let findings = classify_clustering_row_order(&partitions, &schema);
assert!(findings
.iter()
.any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow));
}
#[test]
fn clustering_order_respects_desc_ordering() {
let schema = schema_one_ck(ClusteringOrder::Desc);
let ok = vec![(0usize, vec![ck_int(3), ck_int(2), ck_int(1)])];
assert!(
classify_clustering_row_order(&ok, &schema).is_empty(),
"descending rows under DESC clustering order must be clean"
);
let bad = vec![(0usize, vec![ck_int(1), ck_int(2), ck_int(3)])];
assert!(
classify_clustering_row_order(&bad, &schema)
.iter()
.any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
"ascending rows under a DESC clustering column must be flagged"
);
}
#[test]
fn identity_check_detects_count_mismatch() {
let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
let data = data_partitions(&keys);
let leaves: Vec<BtiResolvedLeaf> = data
.iter()
.take(2)
.map(|(pos, k)| inline_leaf(k, *pos))
.collect();
let detail =
bti_partition_identity_mismatch(&leaves, &data).expect("undercount must be flagged");
assert!(detail.contains("2 partition keys"));
assert!(detail.contains("3 distinct partitions"));
}
#[test]
fn build_component_set_matches_reader_base_name_for_non_data_db_name() {
let p = PathBuf::from("/dir/nb-7-big-Statistics.db");
let set = build_component_set(&[p.clone()], p.clone())
.expect("reader-accepted non-Data.db name must not error");
assert_eq!(
Some(set.base_name),
extract_sstable_base_name(&p),
"verify base name must match SSTableReader::open's base-name derivation"
);
}
#[test]
fn build_component_set_degrades_on_unmappable_name() {
let p = PathBuf::from("/dir/weird.db");
let set = build_component_set(&[], p.clone()).expect("must degrade, not error");
assert_eq!(set.base_name, "weird");
assert_eq!(set.data_path, p);
}
#[test]
fn build_component_set_standard_name_still_resolves_canonically() {
let p = PathBuf::from("/dir/nb-3-big-Data.db");
let set = build_component_set(&[p.clone()], p).expect("standard name resolves");
assert_eq!(set.base_name, "nb-3-big");
}
#[test]
fn generation_dir_normalizes_empty_parent_to_current_dir() {
assert_eq!(
generation_dir(Path::new("nb-1-big-Data.db")),
Path::new("."),
"a relative directory-less Data.db must resolve against the current directory"
);
assert_eq!(
generation_dir(Path::new("/x/y/nb-1-big-Data.db")),
Path::new("/x/y")
);
assert_eq!(
generation_dir(Path::new("sub/nb-1-big-Data.db")),
Path::new("sub")
);
}
}