cqlite_core/storage/sstable/verify.rs
1//! SSTable verifier contract (epic #970, issue #1000).
2//!
3//! This module defines and **enforces** a stable verification contract for
4//! Cassandra 5.0 SSTables — both the `nb`/`big` (legacy `BigFormat`) and the
5//! `da`/`bti` (`BtiFormat`) layouts — covering healthy *and* corrupted inputs.
6//!
7//! # Modes
8//!
9//! Two **distinct** modes are defined. A QUICK pass must never be reported as a
10//! FULL pass: they validate different surfaces.
11//!
12//! * [`VerifyMode::Quick`] — cheap, metadata-only structural checks:
13//! 1. Component presence + `TOC.txt` completeness (every TOC-listed component
14//! must exist on disk).
15//! 2. `Digest.crc32` matches the CRC32 of `Data.db`.
16//! 3. `CompressionInfo.db` parses (unknown algorithm already fail-fasts, #1001)
17//! **and** every declared chunk offset is in-bounds for `Data.db`.
18//! 4. BTI index components (`Partitions.db` / `Rows.db`) parse structurally
19//! (root pointer in-bounds, root node header well-formed).
20//!
21//! * [`VerifyMode::Full`] — QUICK plus deep, content-touching checks:
22//! 5. Inline `Data.db` chunk CRC validation for every chunk (#998 path).
23//! 6. `Statistics.db` parses.
24//! 7. A complete row scan succeeds (exercises LZ4/Snappy/Deflate/Zstd decompression via the stitch path) and does not silently return zero rows when the index/BTI components are structurally corrupt.
25//!
26//! # Error classes
27//!
28//! Every failure is classified into a stable [`VerifyErrorClass`] and reported
29//! through a [`VerifyFinding`] that always carries the failing **component
30//! name** plus locating context (byte offset, chunk index, checksum field, or
31//! the missing-component name). The caller can serialise the resulting
32//! [`VerifyReport`] for CI artifacts.
33//!
34//! # No silent empty results on corruption (#1000)
35//!
36//! Prior to this contract a corrupted `Index.db` (BIG) or a corrupted/truncated
37//! `Partitions.db`/`Rows.db` (BTI) could pass through the read path and yield an
38//! apparently-successful **zero-row** scan, masking structural corruption. The
39//! FULL verifier closes that hole: the structural index checks run first and
40//! hard-error, so a corrupt index is never reported as "verified, 0 rows".
41
42use crate::platform::Platform;
43use crate::storage::sstable::compression_info::CompressionInfo;
44use crate::storage::sstable::reader::{extract_sstable_base_name, SSTableReader};
45use crate::storage::sstable::version_gate::{SsTableDescriptor, SsTableFormat};
46use crate::{Config, Error, Result};
47use std::collections::BTreeMap;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50
51/// Verification depth. QUICK and FULL are intentionally distinct — see the
52/// module docs. A QUICK success MUST NOT be presented as FULL corruption
53/// parity.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum VerifyMode {
56 /// Metadata-only structural checks (component presence, TOC, digest,
57 /// CompressionInfo bounds, BTI root structure).
58 Quick,
59 /// QUICK plus inline chunk-CRC validation, Statistics.db parse, and a full
60 /// row scan.
61 Full,
62}
63
64impl VerifyMode {
65 /// Stable lower-case label for reports/CLIs.
66 pub fn as_str(self) -> &'static str {
67 match self {
68 VerifyMode::Quick => "quick",
69 VerifyMode::Full => "full",
70 }
71 }
72}
73
74/// Stable classification of a verification failure.
75///
76/// The variant is the machine-checkable "error code"; the [`VerifyFinding`]
77/// carries the human-readable context. These names are part of the verifier
78/// contract — callers (and CI) may match on them, so they must remain stable.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum VerifyErrorClass {
81 /// A `TOC.txt`-listed component (or a structurally-required component) is
82 /// absent from disk.
83 MissingComponent,
84 /// `Digest.crc32` does not match the computed CRC32 of `Data.db`.
85 DigestMismatch,
86 /// `CompressionInfo.db` failed to parse, named an unsupported algorithm, or
87 /// otherwise malformed (#1001).
88 CompressionInfoCorrupt,
89 /// A `CompressionInfo.db` chunk offset points outside `Data.db`.
90 ChunkOffsetOutOfBounds,
91 /// An inline `Data.db` chunk CRC32 did not match, or a chunk could not be
92 /// read / decompressed (truncation, bit flip).
93 ChunkDecompressionError,
94 /// A chunk is compressed with a valid but UNSUPPORTED compression feature —
95 /// distinct from truncation/bit-flip ([`ChunkDecompressionError`]) and from a
96 /// checksum mismatch ([`DigestMismatch`]) (issue #1414). The canonical case is
97 /// a **zstd dictionary-compressed** chunk: the frame is well-formed and its
98 /// inline chunk CRC is valid, but CQLite ships no-dictionary zstd only, so the
99 /// frame cannot be decoded. The reader fails closed with
100 /// `Error::UnsupportedFormat` naming the feature (e.g. the `Dictionary_ID`);
101 /// this class makes the verify report say "unsupported feature", never
102 /// "corruption".
103 ///
104 /// [`ChunkDecompressionError`]: VerifyErrorClass::ChunkDecompressionError
105 /// [`DigestMismatch`]: VerifyErrorClass::DigestMismatch
106 UnsupportedCompressionFeature,
107 /// An **uncompressed** BIG `Data.db` chunk did not match its stored `CRC.db`
108 /// per-chunk CRC32 (issue #1396) — the uncompressed analogue of the compressed
109 /// path's inline chunk-CRC finding ([`ChunkDecompressionError`]). Cassandra
110 /// writes a `CRC.db` for every uncompressed BIG SSTable and verifies reads
111 /// against it; a bit flip inside an uncompressed chunk is detected here (and,
112 /// default-on, on every read). Also covers a truncated / short `CRC.db` (fewer
113 /// per-chunk CRC entries than the Data.db has chunks). Reported via a
114 /// `VerifyFinding` naming the failing chunk and the `CRC.db`/`Data.db`
115 /// component.
116 ///
117 /// [`ChunkDecompressionError`]: VerifyErrorClass::ChunkDecompressionError
118 UncompressedChunkCrcMismatch,
119 /// A component was truncated and a required read hit end-of-file.
120 UnexpectedEof,
121 /// `Index.db` (BIG) is structurally corrupt.
122 IndexEntryCorrupt,
123 /// `Statistics.db` header / body is corrupt.
124 StatisticsHeaderCorrupt,
125 /// `Summary.db` is truncated / unreadable.
126 SummaryCorrupt,
127 /// BTI `Partitions.db` root pointer / node is corrupt.
128 BtiRootPointerCorrupt,
129 /// BTI `Rows.db` trie is truncated / corrupt.
130 BtiTrieCorrupt,
131 /// A full row scan failed for a reason not otherwise classified above.
132 RowScanFailed,
133 /// Partition keys are not in ascending on-disk (Murmur3 token) order, or
134 /// clustering rows within a partition are not in ascending clustering order
135 /// (issue #1282). Cassandra requires strictly ordered keys/rows; its
136 /// `sstableverify` (`SSTableIdentityIterator` / `Verifier`) rejects an
137 /// out-of-order key or row as corrupt.
138 OutOfOrderKeyOrRow,
139 /// A partition-level `localDeletionTime` is negative (invalid) on the legacy
140 /// signed (`nb`) `DeletionTime` form (issue #1282). `localDeletionTime` is
141 /// seconds since the Unix epoch; the only non-negative "special" value is the
142 /// live sentinel `i32::MAX` (`0x7FFFFFFF`). A negative value cannot be a valid
143 /// deletion time — Cassandra's `DeletionTime`/`Verifier` treats it as corrupt.
144 /// (The unsigned `oa`/`da` form legitimately represents far-future times in
145 /// `[2^31, 2^32)`, so those are NOT flagged — the on-disk format, not a
146 /// heuristic, decides.)
147 InvalidLocalDeletionTime,
148 /// A parseable BIG `Filter.db` reports "not present" (`might_contain == false`)
149 /// for a partition key that IS present in the SSTable (its raw key bytes are
150 /// enumerated from the authoritative `Index.db`) — a Bloom-filter FALSE
151 /// NEGATIVE (issue #1398). Cassandra's `Filter.db` carries no checksum, so a
152 /// bit flipped from 1→0 inside the bit array is not detected on load and makes
153 /// a live partition silently invisible on the BIG point-lookup path
154 /// (`partition_lookup.rs` returns `Ok(None)` when the bloom says "miss"). Full
155 /// scans and BTI (`da`) lookups are UNAFFECTED (they never gate on this bloom),
156 /// so this is a detection tool Cassandra's `sstableverify` lacks — Cassandra
157 /// does not verify Filter.db contents and would report the same fixture clean.
158 FilterFalseNegative,
159}
160
161impl VerifyErrorClass {
162 /// Stable string code for the error class (used in reports / CI artifacts).
163 pub fn code(self) -> &'static str {
164 match self {
165 VerifyErrorClass::MissingComponent => "MissingComponent",
166 VerifyErrorClass::DigestMismatch => "DigestMismatch",
167 VerifyErrorClass::CompressionInfoCorrupt => "CompressionInfoCorrupt",
168 VerifyErrorClass::ChunkOffsetOutOfBounds => "ChunkOffsetOutOfBounds",
169 VerifyErrorClass::ChunkDecompressionError => "ChunkDecompressionError",
170 VerifyErrorClass::UnsupportedCompressionFeature => "UnsupportedCompressionFeature",
171 VerifyErrorClass::UncompressedChunkCrcMismatch => "UncompressedChunkCrcMismatch",
172 VerifyErrorClass::UnexpectedEof => "UnexpectedEof",
173 VerifyErrorClass::IndexEntryCorrupt => "IndexEntryCorrupt",
174 VerifyErrorClass::StatisticsHeaderCorrupt => "StatisticsHeaderCorrupt",
175 VerifyErrorClass::SummaryCorrupt => "SummaryCorrupt",
176 VerifyErrorClass::BtiRootPointerCorrupt => "BtiRootPointerCorrupt",
177 VerifyErrorClass::BtiTrieCorrupt => "BtiTrieCorrupt",
178 VerifyErrorClass::RowScanFailed => "RowScanFailed",
179 VerifyErrorClass::OutOfOrderKeyOrRow => "OutOfOrderKeyOrRow",
180 VerifyErrorClass::InvalidLocalDeletionTime => "InvalidLocalDeletionTime",
181 VerifyErrorClass::FilterFalseNegative => "FilterFalseNegative",
182 }
183 }
184}
185
186impl std::fmt::Display for VerifyErrorClass {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.write_str(self.code())
189 }
190}
191
192/// A single verification failure: a stable class plus the failing component and
193/// locating context. Always serialisable by the caller (all fields are owned).
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct VerifyFinding {
196 /// Stable error classification.
197 pub class: VerifyErrorClass,
198 /// SSTable component name that failed (e.g. `Data.db`, `Index.db`,
199 /// `Partitions.db`, `TOC.txt`).
200 pub component: String,
201 /// Human-readable message including locating context (offset / chunk index
202 /// / checksum field / missing-component name).
203 pub detail: String,
204}
205
206impl VerifyFinding {
207 fn new(
208 class: VerifyErrorClass,
209 component: impl Into<String>,
210 detail: impl Into<String>,
211 ) -> Self {
212 Self {
213 class,
214 component: component.into(),
215 detail: detail.into(),
216 }
217 }
218}
219
220impl std::fmt::Display for VerifyFinding {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 write!(
223 f,
224 "[{}] {}: {}",
225 self.class.code(),
226 self.component,
227 self.detail
228 )
229 }
230}
231
232/// Structured outcome of a verification run. Serialise this for CI artifacts.
233#[derive(Debug, Clone)]
234pub struct VerifyReport {
235 /// Directory that was verified.
236 pub directory: PathBuf,
237 /// SSTable base name (e.g. `nb-1-big`, `da-2-bti`).
238 pub base_name: String,
239 /// Detected on-disk format.
240 pub format: SsTableFormat,
241 /// Mode the verification was run in.
242 pub mode: VerifyMode,
243 /// All findings (empty when verification passed).
244 pub findings: Vec<VerifyFinding>,
245 /// Components named in `TOC.txt` (if a TOC was present).
246 pub toc_components: Vec<String>,
247 /// Number of rows seen during the FULL-mode scan (`None` in QUICK mode).
248 pub rows_scanned: Option<usize>,
249}
250
251impl VerifyReport {
252 /// `true` when no findings were recorded (verification passed).
253 pub fn is_ok(&self) -> bool {
254 self.findings.is_empty()
255 }
256
257 /// The first finding's error class, if any.
258 pub fn primary_class(&self) -> Option<VerifyErrorClass> {
259 self.findings.first().map(|f| f.class)
260 }
261
262 /// Render a single-line summary suitable for logs / CI artifacts.
263 pub fn summary_line(&self) -> String {
264 if self.is_ok() {
265 format!(
266 "VERIFY OK [{}/{}] {} ({} rows)",
267 self.mode.as_str(),
268 self.format.as_str(),
269 self.base_name,
270 self.rows_scanned
271 .map(|n| n.to_string())
272 .unwrap_or_else(|| "-".to_string()),
273 )
274 } else {
275 format!(
276 "VERIFY FAIL [{}/{}] {}: {}",
277 self.mode.as_str(),
278 self.format.as_str(),
279 self.base_name,
280 self.findings
281 .iter()
282 .map(|f| f.to_string())
283 .collect::<Vec<_>>()
284 .join("; "),
285 )
286 }
287 }
288}
289
290/// Resolved set of component files for one SSTable generation in a directory.
291struct ComponentSet {
292 base_name: String,
293 format: SsTableFormat,
294 /// Map of bare component name (e.g. `Data.db`) -> absolute path on disk.
295 present: BTreeMap<String, PathBuf>,
296 data_path: PathBuf,
297}
298
299impl ComponentSet {
300 fn path(&self, dir: &Path, component: &str) -> PathBuf {
301 dir.join(format!("{}-{}", self.base_name, component))
302 }
303
304 /// `true` when a component (e.g. `Statistics.db`) is present on disk for
305 /// this SSTable generation, per the directory scan performed at resolution
306 /// time.
307 fn has(&self, component: &str) -> bool {
308 self.present.contains_key(component)
309 }
310}
311
312/// Verify a single SSTable generation located in `dir`.
313///
314/// `dir` must contain exactly one SSTable generation (i.e. one `*-Data.db`); if
315/// it contains several, the lexicographically-first generation is selected.
316///
317/// Returns a [`VerifyReport`]. The function only returns `Err` for environmental
318/// problems (the directory cannot be read, or it contains no `Data.db`); *data*
319/// corruption is reported as findings inside an `Ok(VerifyReport)` so the caller
320/// can serialise the full picture. Use [`VerifyReport::is_ok`] to branch.
321pub async fn verify_sstable(
322 dir: &Path,
323 mode: VerifyMode,
324 config: &Config,
325 platform: Arc<Platform>,
326) -> Result<VerifyReport> {
327 let components = resolve_components(dir)?;
328 verify_components(dir, components, mode, config, platform).await
329}
330
331/// Verify the EXACT SSTable generation identified by `data_db_path`.
332///
333/// Additive companion to [`verify_sstable`] (issue #1283, roborev). `verify_sstable`
334/// resolves the *lexicographically-first* `*-Data.db` in a directory, which is the
335/// wrong generation when a directory holds several: an `SSTableReader` opened on
336/// generation N would otherwise report the integrity of whichever `Data.db` sorts
337/// first. This entry point verifies precisely the generation whose components share
338/// `data_db_path`'s base name (e.g. `nb-2-big`), so a caller that already knows its
339/// own `Data.db` (an open reader) gets a verdict for THAT generation.
340///
341/// `data_db_path` must be an existing `*-Data.db` file; its parent directory supplies
342/// the sibling components. Returns a [`VerifyReport`] with the same corruption-as-
343/// findings contract as [`verify_sstable`].
344pub async fn verify_sstable_generation(
345 data_db_path: &Path,
346 mode: VerifyMode,
347 config: &Config,
348 platform: Arc<Platform>,
349) -> Result<VerifyReport> {
350 let dir = generation_dir(data_db_path);
351 let components = resolve_components_for_data_path(dir, data_db_path)?;
352 verify_components(dir, components, mode, config, platform).await
353}
354
355/// Resolve the directory that holds `data_db_path`'s sibling components.
356///
357/// `Path::parent()` returns an EMPTY path (not `None`) for a relative,
358/// directory-less filename (e.g. `nb-1-big-Data.db` opened from the SSTable dir as
359/// cwd), so a naive scan would look in the empty path instead of the current
360/// directory and fail component resolution — even though `SSTableReader::open`
361/// found the file (its sibling lookup joins onto the empty parent, which resolves
362/// relative to cwd). Normalize a missing/empty parent to `.` so component
363/// resolution scans the current directory (issue #1283, roborev).
364fn generation_dir(data_db_path: &Path) -> &Path {
365 match data_db_path.parent() {
366 Some(p) if !p.as_os_str().is_empty() => p,
367 _ => Path::new("."),
368 }
369}
370
371/// Shared verification body: runs all checks over an already-resolved
372/// [`ComponentSet`]. Both [`verify_sstable`] (first-generation resolution) and
373/// [`verify_sstable_generation`] (exact-generation resolution) delegate here so the
374/// check pipeline is defined exactly once (issue #1283).
375async fn verify_components(
376 dir: &Path,
377 components: ComponentSet,
378 mode: VerifyMode,
379 config: &Config,
380 platform: Arc<Platform>,
381) -> Result<VerifyReport> {
382 let mut findings: Vec<VerifyFinding> = Vec::new();
383
384 // ---- Check 1: TOC.txt completeness + component presence ----------------
385 let toc_components = check_toc_and_presence(dir, &components, &mut findings)?;
386
387 // ---- Check 2: Digest.crc32 vs CRC32(Data.db) ---------------------------
388 check_digest(dir, &components, &mut findings)?;
389
390 // ---- Check 3: CompressionInfo.db parse + chunk-offset bounds -----------
391 let compression_info = check_compression_info(dir, &components, &mut findings)?;
392
393 // ---- Check 4: index structure (Index.db for BIG, BTI tries for BTI) ----
394 //
395 // This is the heart of the "no silent empty results on corruption" mandate
396 // (#1000). The BIG read path silently TRUNCATES the partition list on the
397 // first malformed Index.db entry (index_reader.rs stops the parse loop and
398 // returns the partitions parsed so far), and the full scan then falls back
399 // to a whole-Data.db scan — so a corrupt Index.db otherwise looks healthy.
400 // For BTI, a full scan reads Data.db directly and never touches the
401 // Partitions.db/Rows.db tries, so a corrupt trie is likewise invisible to a
402 // scan. We validate the index structurally here and hard-fail.
403 //
404 // `bti_leaves` is the set of partition-index leaves recovered by walking
405 // Partitions.db; it is cross-checked against the Data.db scan in FULL mode to
406 // catch a footer-flip that silently UNDER-counts partitions (the trie still
407 // parses, just from the wrong root) AND a same-count corruption that keeps a
408 // leaf's emitted prefix but rewrites its PAYLOAD to point at a different
409 // partition. Each leaf carries its emitted byte-comparable prefix plus its
410 // payload resolved back to a raw partition key by AUTHORITATIVE data (issue
411 // #1103).
412 let mut bti_leaves: Option<Vec<BtiResolvedLeaf>> = None;
413 match components.format {
414 SsTableFormat::Bti => bti_leaves = check_bti_structure(dir, &components, &mut findings)?,
415 SsTableFormat::Big => check_big_index(dir, &components, &mut findings)?,
416 }
417
418 let mut rows_scanned = None;
419
420 if mode == VerifyMode::Full {
421 // ---- Check 5: inline Data.db chunk CRC validation (#998) -----------
422 if let Some(info) = compression_info.as_ref() {
423 check_inline_chunk_crc(&components, info, &mut findings)?;
424 } else if components.format == SsTableFormat::Big {
425 // ---- Check 5b: uncompressed CRC.db per-chunk validation (#1396) --
426 // An uncompressed BIG SSTable (no CompressionInfo.db) carries a CRC.db
427 // per-chunk checksum sidecar. Read it and validate every Data.db chunk
428 // — the uncompressed analogue of the inline chunk-CRC check above.
429 // Replaces the prior behavior where CRC.db was only name-whitelisted
430 // (recognized as a component) but never content-validated.
431 check_uncompressed_crc_db(dir, &components, &mut findings).await;
432 }
433
434 // ---- Check 6a: Statistics.db parse ---------------------------------
435 check_statistics(dir, &components, platform.clone(), &mut findings).await;
436
437 // ---- Check 6b: Summary.db parse (BIG only) -------------------------
438 if components.format == SsTableFormat::Big {
439 check_summary(dir, &components, platform.clone(), &mut findings).await;
440 }
441
442 // ---- Check 6c: Filter.db no-false-negative membership (BIG only) ----
443 //
444 // A parseable Filter.db with a bit flipped 1→0 inside the bit array is
445 // NOT detected on load (Cassandra's Filter.db has no checksum, and the
446 // read path is fail-open only for UNPARSEABLE filters) yet yields false
447 // negatives: `might_contain == false` for a present key makes the BIG
448 // point-lookup path return Ok(None) — a live partition silently invisible
449 // (issue #1398). Cassandra's sstableverify does not verify Filter.db
450 // contents, so this is a detection tool Cassandra lacks. BTI is immune
451 // (bloom bypassed for the trie) and full scans never gate on the bloom, so
452 // this check is BIG-only and probes the authoritative Index.db present
453 // keys against the decoded filter.
454 if components.format == SsTableFormat::Big {
455 check_filter_false_negatives(dir, &components, platform.clone(), &mut findings).await;
456 }
457
458 // ---- Check 7: full row scan (no silent empty on corruption) --------
459 //
460 // Skip the scan when compression metadata is already known-corrupt: the
461 // corruption is reported, and scanning would re-read the bad
462 // CompressionInfo.db and drive the chunk reader off an out-of-bounds
463 // offset. The reader now bounds-checks and errors rather than panicking
464 // (block_io.rs), but there is no value in scanning metadata we have
465 // already flagged (roborev #970).
466 let compression_metadata_corrupt = findings.iter().any(|f| {
467 matches!(
468 f.class,
469 VerifyErrorClass::CompressionInfoCorrupt | VerifyErrorClass::ChunkOffsetOutOfBounds
470 )
471 });
472 if !compression_metadata_corrupt {
473 // The structural index checks (1, 4) above already hard-fail on a
474 // corrupt Index.db / BTI trie BEFORE we ever scan, so a corrupt index
475 // can never be reported as a successful zero-row scan. We still run the
476 // scan to exercise the decompression stitch path and surface Data.db
477 // corruption that only manifests during decode.
478 // The order/LDT check (Check 8) reuses the reader, so keep a clone of
479 // the platform handle before the scan consumes the original.
480 let platform_for_order = platform.clone();
481 match full_row_scan_partitions(&components.data_path, config, platform).await {
482 Ok((rows, scan_partitions)) => {
483 rows_scanned = Some(rows);
484 // BTI cross-check: each Partitions.db leaf's PAYLOAD, resolved
485 // back to a raw partition key by authoritative data, MUST match
486 // the partition keys decoded from Data.db — by IDENTITY, not
487 // just count (issue #1103). A count-only check passes a
488 // corruption that walks a wrong subtree yielding a different set
489 // of keys with the same leaf count; a prefix-only check passes a
490 // corruption that keeps a leaf's emitted prefix but rewrites its
491 // payload to a different partition. Resolving the payload closes
492 // both gaps.
493 if let Some(leaves) = bti_leaves {
494 if let Some(detail) =
495 bti_partition_identity_mismatch(&leaves, &scan_partitions)
496 {
497 findings.push(VerifyFinding::new(
498 VerifyErrorClass::BtiRootPointerCorrupt,
499 "Partitions.db",
500 detail,
501 ));
502 }
503 }
504 }
505 Err(e) => findings.push(classify_scan_error(&components, &e)),
506 }
507
508 // ---- Check 8: key/row order + partition-level LDT validity (#1282)
509 //
510 // Cassandra's `sstableverify` rejects two corruption classes CQLite
511 // did not previously classify: partition keys / clustering rows out of
512 // ascending order, and a negative (invalid) partition-level
513 // `localDeletionTime`. Both are read off the SAME authoritative decode
514 // the scan already performs (no second heuristic pass): the on-disk
515 // partition order (Murmur3 token order) and each deleted partition's
516 // raw `DeletionTime`. Skipped when compression metadata is corrupt
517 // (handled above) — this block is inside the same guard.
518 check_key_order_and_ldt(
519 &components.data_path,
520 config,
521 platform_for_order,
522 &mut findings,
523 )
524 .await;
525 } // end: if !compression_metadata_corrupt
526 }
527
528 Ok(VerifyReport {
529 directory: dir.to_path_buf(),
530 base_name: components.base_name,
531 format: components.format,
532 mode,
533 findings,
534 toc_components,
535 rows_scanned,
536 })
537}
538
539/// Read all regular files in `dir`, returning `(all_files, data_files)` where
540/// `data_files` is the subset ending in `-Data.db`.
541fn read_dir_files(dir: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
542 let entries = std::fs::read_dir(dir).map_err(|e| {
543 Error::invalid_path(format!("Cannot read SSTable dir {}: {}", dir.display(), e))
544 })?;
545
546 let mut data_files: Vec<PathBuf> = Vec::new();
547 let mut all_files: Vec<PathBuf> = Vec::new();
548 for entry in entries.flatten() {
549 let p = entry.path();
550 if !p.is_file() {
551 continue;
552 }
553 all_files.push(p.clone());
554 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
555 if name.ends_with("-Data.db") {
556 data_files.push(p);
557 }
558 }
559 }
560 Ok((all_files, data_files))
561}
562
563/// Locate the SSTable generation in `dir` and enumerate its on-disk components.
564///
565/// If `dir` contains several generations, the lexicographically-first `*-Data.db`
566/// is selected (documented behavior of [`verify_sstable`]). To verify a SPECIFIC
567/// generation, use [`resolve_components_for_data_path`] / [`verify_sstable_generation`].
568fn resolve_components(dir: &Path) -> Result<ComponentSet> {
569 let (all_files, mut data_files) = read_dir_files(dir)?;
570
571 data_files.sort();
572 let data_path = data_files.into_iter().next().ok_or_else(|| {
573 Error::not_found(format!(
574 "No *-Data.db component found in SSTable directory {}",
575 dir.display()
576 ))
577 })?;
578
579 build_component_set(&all_files, data_path)
580}
581
582/// Enumerate the components for the EXACT generation identified by `data_path`
583/// within `dir`. Unlike [`resolve_components`], this does not pick the first-sorted
584/// generation; it uses precisely the caller-supplied `data_path` (issue #1283).
585fn resolve_components_for_data_path(dir: &Path, data_path: &Path) -> Result<ComponentSet> {
586 if !data_path.is_file() {
587 return Err(Error::not_found(format!(
588 "SSTable Data.db component not found at {}",
589 data_path.display()
590 )));
591 }
592 let (all_files, _data_files) = read_dir_files(dir)?;
593 build_component_set(&all_files, data_path.to_path_buf())
594}
595
596/// Build a [`ComponentSet`] for `data_path`, indexing the sibling components in
597/// `all_files` that share its base name.
598fn build_component_set(all_files: &[PathBuf], data_path: PathBuf) -> Result<ComponentSet> {
599 let data_name = data_path
600 .file_name()
601 .and_then(|n| n.to_str())
602 .ok_or_else(|| Error::invalid_path("Data.db filename is not valid UTF-8"))?;
603 // Derive the base name with the SAME tolerance as `SSTableReader::open`, which
604 // locates its sibling components via `extract_sstable_base_name` and still opens
605 // a file whose name it cannot map (it simply skips the siblings). A reader that
606 // opened successfully MUST get an `IntegrityCheckResult`, not an `Err`, so this
607 // never rejects on the "-Data.db" suffix (issue #1283, roborev):
608 // 1. standard names end in "-Data.db" -> strip it (e.g. "nb-1-big");
609 // 2. otherwise fall back to the reader's own base-name derivation (descriptor
610 // parse, then the {prefix}-{gen}-{format} heuristic) so any non-standard
611 // name the reader accepts resolves the same base here;
612 // 3. if even that cannot map the name, degrade to the filename minus its ".db"
613 // extension so we still verify what we can (Data.db digest, chunk CRCs)
614 // rather than erroring — matching reader-open tolerance.
615 let base_name = data_name
616 .strip_suffix("-Data.db")
617 .map(str::to_string)
618 .or_else(|| extract_sstable_base_name(&data_path))
619 .unwrap_or_else(|| {
620 data_name
621 .strip_suffix(".db")
622 .unwrap_or(data_name)
623 .to_string()
624 });
625
626 // Detect format via the descriptor parser, which scans for the "big"/"bti"
627 // segment correctly even when the SSTable id is a hyphenated UUID
628 // (e.g. "da-00000000-0000-0000-0000-000000000001-bti-Data.db"). A fixed
629 // dash-index split would misread those as BIG and verify the wrong
630 // components (roborev).
631 let format = SsTableDescriptor::parse_filename(data_name)
632 .map(|d| d.format)
633 .unwrap_or(SsTableFormat::Big);
634
635 // Index present components for this base name only.
636 let prefix = format!("{}-", base_name);
637 let mut present = BTreeMap::new();
638 for p in all_files {
639 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
640 if let Some(component) = name.strip_prefix(&prefix) {
641 present.insert(component.to_string(), p.clone());
642 }
643 }
644 }
645
646 Ok(ComponentSet {
647 base_name,
648 format,
649 present,
650 data_path,
651 })
652}
653
654/// `true` when `component` is a real Cassandra SSTable component name (the set
655/// that can legitimately appear in `TOC.txt`). Excludes test sidecars such as
656/// `Data.db.jsonl` or `Statistics.db.txt` reference goldens that share the base
657/// prefix in the dataset directories.
658fn is_real_component(component: &str) -> bool {
659 matches!(
660 component,
661 "TOC.txt" | "Digest.crc32" | "Digest.adler32" | "Digest.sha1" | "CRC.db"
662 ) || (component.ends_with(".db") && !component.contains(".db."))
663}
664
665/// Check 1: every component listed in `TOC.txt` exists on disk. Also surfaces a
666/// structurally-required-but-missing `Data.db`.
667fn check_toc_and_presence(
668 dir: &Path,
669 components: &ComponentSet,
670 findings: &mut Vec<VerifyFinding>,
671) -> Result<Vec<String>> {
672 // Data.db is always required.
673 if !components.data_path.exists() {
674 findings.push(VerifyFinding::new(
675 VerifyErrorClass::MissingComponent,
676 "Data.db",
677 format!(
678 "required Data.db not found at {}",
679 components.data_path.display()
680 ),
681 ));
682 }
683
684 let toc_path = components.path(dir, "TOC.txt");
685 if !toc_path.exists() {
686 // No TOC at all: not a hard error here (some tooling omits it), but
687 // record it as a missing component so it is visible.
688 findings.push(VerifyFinding::new(
689 VerifyErrorClass::MissingComponent,
690 "TOC.txt",
691 format!("TOC.txt not present at {}", toc_path.display()),
692 ));
693 return Ok(Vec::new());
694 }
695
696 let toc_raw = std::fs::read_to_string(&toc_path).map_err(|e| {
697 Error::corruption(format!(
698 "Cannot read TOC.txt at {}: {}",
699 toc_path.display(),
700 e
701 ))
702 })?;
703
704 let mut listed = Vec::new();
705 for line in toc_raw.lines() {
706 let component = line.trim();
707 if component.is_empty() {
708 continue;
709 }
710 listed.push(component.to_string());
711
712 // The TOC lists bare component names (e.g. "Statistics.db"). Check it
713 // against the directory scan captured at resolution time.
714 if !components.has(component) {
715 let expected = components.path(dir, component);
716 findings.push(VerifyFinding::new(
717 VerifyErrorClass::MissingComponent,
718 component.to_string(),
719 format!(
720 "TOC.txt lists component '{}' but '{}' is absent on disk",
721 component,
722 expected.display()
723 ),
724 ));
725 }
726 }
727
728 // Inverse direction: Cassandra's TOC.txt enumerates EVERY component it
729 // wrote. A component that is present on disk but missing from the TOC means
730 // the TOC is incomplete/corrupt (the `toc_missing_component` corruption
731 // drops the `Statistics.db` line while the file stays on disk). Report each
732 // present-but-unlisted component as a missing TOC entry.
733 for present in components.present.keys() {
734 // Only real SSTable components participate in the TOC. Skip sidecar /
735 // reference files that share the base prefix (e.g. `Data.db.jsonl`,
736 // `Statistics.db.txt` goldens) so they don't masquerade as missing TOC
737 // entries on an otherwise-healthy generation.
738 if !is_real_component(present) {
739 continue;
740 }
741 if !listed.iter().any(|c| c == present) {
742 findings.push(VerifyFinding::new(
743 VerifyErrorClass::MissingComponent,
744 present.clone(),
745 format!(
746 "component '{}' is present on disk but not listed in TOC.txt (incomplete/corrupt TOC)",
747 present
748 ),
749 ));
750 }
751 }
752
753 Ok(listed)
754}
755
756/// Check 2: `Digest.crc32` matches CRC32 of `Data.db`.
757///
758/// Cassandra writes `Digest.crc32` as the decimal-ASCII CRC32 (IEEE) of the
759/// entire `Data.db` file (including inline chunk CRCs).
760fn check_digest(
761 dir: &Path,
762 components: &ComponentSet,
763 findings: &mut Vec<VerifyFinding>,
764) -> Result<()> {
765 let digest_path = components.path(dir, "Digest.crc32");
766 if !digest_path.exists() {
767 // Absence handled by the TOC check if it was listed; nothing to compare.
768 return Ok(());
769 }
770 let digest_text = std::fs::read_to_string(&digest_path).map_err(|e| {
771 Error::corruption(format!(
772 "Cannot read Digest.crc32 at {}: {}",
773 digest_path.display(),
774 e
775 ))
776 })?;
777 // Parse strictly as u32: a CRC32 digest cannot exceed u32::MAX. Parsing as
778 // u64 + truncating would accept an oversized value whose low 32 bits happen
779 // to match the computed CRC (roborev).
780 let recorded: u32 = match digest_text.trim().parse::<u32>() {
781 Ok(v) => v,
782 Err(e) => {
783 findings.push(VerifyFinding::new(
784 VerifyErrorClass::DigestMismatch,
785 "Digest.crc32",
786 format!(
787 "Digest.crc32 is not a valid integer ('{}'): {}",
788 digest_text.trim(),
789 e
790 ),
791 ));
792 return Ok(());
793 }
794 };
795
796 let data = match std::fs::read(&components.data_path) {
797 Ok(d) => d,
798 Err(e) => {
799 findings.push(VerifyFinding::new(
800 VerifyErrorClass::MissingComponent,
801 "Data.db",
802 format!("cannot read Data.db for digest check: {}", e),
803 ));
804 return Ok(());
805 }
806 };
807 let computed = crc32fast::hash(&data);
808 if computed != recorded {
809 findings.push(VerifyFinding::new(
810 VerifyErrorClass::DigestMismatch,
811 "Digest.crc32",
812 format!(
813 "Digest.crc32 mismatch: recorded={} (0x{:08x}), computed={} (0x{:08x}) over {} bytes of Data.db",
814 recorded, recorded, computed, computed, data.len()
815 ),
816 ));
817 }
818 Ok(())
819}
820
821/// Check 3: `CompressionInfo.db` parses (#1001) and all chunk offsets are
822/// in-bounds for `Data.db`. Returns the parsed `CompressionInfo` for reuse by
823/// the FULL-mode inline-CRC check, or `None` (genuinely uncompressed table, or
824/// the file failed to parse — in which case a finding is recorded).
825fn check_compression_info(
826 dir: &Path,
827 components: &ComponentSet,
828 findings: &mut Vec<VerifyFinding>,
829) -> Result<Option<CompressionInfo>> {
830 let ci_path = components.path(dir, "CompressionInfo.db");
831 if !ci_path.exists() {
832 return Ok(None); // uncompressed SSTable
833 }
834 let bytes = std::fs::read(&ci_path).map_err(|e| {
835 Error::corruption(format!(
836 "Cannot read CompressionInfo.db at {}: {}",
837 ci_path.display(),
838 e
839 ))
840 })?;
841
842 let info = match CompressionInfo::parse(&bytes) {
843 Ok(info) => info,
844 Err(e) => {
845 findings.push(VerifyFinding::new(
846 VerifyErrorClass::CompressionInfoCorrupt,
847 "CompressionInfo.db",
848 format!("CompressionInfo.db failed to parse: {}", e),
849 ));
850 return Ok(None);
851 }
852 };
853
854 // Bounds-check declared chunk offsets against the actual Data.db length.
855 // `CompressionInfo::validate()` only enforces ascending order; a single
856 // corrupted offset (e.g. an MSB set) is ascending yet points past EOF.
857 let data_len = match std::fs::metadata(&components.data_path) {
858 Ok(m) => m.len(),
859 Err(e) => {
860 findings.push(VerifyFinding::new(
861 VerifyErrorClass::MissingComponent,
862 "Data.db",
863 format!("cannot stat Data.db for chunk-bounds check: {}", e),
864 ));
865 return Ok(Some(info));
866 }
867 };
868 let mut offset_out_of_bounds = false;
869 for (i, &offset) in info.chunk_offsets.iter().enumerate() {
870 // Every chunk record is at least its 4-byte inline CRC, so the offset
871 // itself must leave room for that. Offsets at/after EOF are corrupt.
872 if offset.saturating_add(4) > data_len {
873 offset_out_of_bounds = true;
874 findings.push(VerifyFinding::new(
875 VerifyErrorClass::ChunkOffsetOutOfBounds,
876 "CompressionInfo.db",
877 format!(
878 "chunk[{}] offset {} (0x{:x}) points past Data.db end ({} bytes)",
879 i, offset, offset, data_len
880 ),
881 ));
882 }
883 }
884
885 // An out-of-bounds offset is corrupt metadata: do NOT hand it downstream.
886 // The inline-CRC check derives each chunk's compressed size from adjacent
887 // offsets, which would underflow (panic in debug / huge alloc in release) on
888 // a bad offset — violating the corruption-as-findings contract. The finding
889 // is already recorded, so returning None just skips the chunk-CRC check
890 // (roborev).
891 if offset_out_of_bounds {
892 return Ok(None);
893 }
894
895 Ok(Some(info))
896}
897
898/// One BTI `Partitions.db` leaf, with its PAYLOAD resolved back to a raw
899/// partition key using authoritative data (issue #1103).
900///
901/// The verifier resolves every leaf so a corruption that keeps the leaf's
902/// emitted byte-comparable prefix while rewriting its payload to point at a
903/// DIFFERENT partition is still caught (a same-count, wrong-IDENTITY
904/// corruption the prefix-only compare missed).
905struct BtiResolvedLeaf {
906 /// The path-compressed byte-comparable prefix emitted by the trie walk
907 /// (`[0x40 ++ token]` truncated to the shortest distinguishing prefix). Used
908 /// only for the prefix/payload-consistency assertion.
909 prefix: Vec<u8>,
910 /// The raw partition key this leaf's payload resolves to, when it could be
911 /// recovered directly (a `RowsOffset` leaf stores the raw key INLINE in
912 /// `Rows.db`). `None` for a `DataOffset` leaf, whose raw key is recovered via
913 /// the Data.db position map ([`Self::data_position`]).
914 inline_raw_key: Option<Vec<u8>>,
915 /// The decompressed-`Data.db` partition-start position the payload points at:
916 /// the `DataOffset` value directly, or the `data_position` recovered from the
917 /// `RowsOffset` row-index entry. Resolved to a raw key via the Data.db scan's
918 /// position map in [`bti_partition_identity_mismatch`].
919 data_position: u64,
920}
921
922/// Check 4 (BTI): structurally validate the `Partitions.db` and `Rows.db`
923/// tries, and resolve every partition-index leaf back to a raw partition key.
924///
925/// Returns `Some(leaves)` — one [`BtiResolvedLeaf`] per recovered partition —
926/// so the caller can cross-check them against the Data.db scan by IDENTITY
927/// (FULL mode). Returns `None` if `Partitions.db` could not be walked (a finding
928/// was recorded).
929///
930/// * `Partitions.db` is walked with [`iterate_partitions_in_bti_file`], which
931/// follows the trailing-8-byte footer root and DFS-collects every leaf. A
932/// footer flip either makes the walk error (out-of-bounds root) or silently
933/// recover the wrong key set; the FULL-mode identity cross-check catches the
934/// latter.
935/// * For every partition whose payload is a `RowsOffset`, the per-partition
936/// row-index entry is resolved from `Rows.db` via [`iterate_rows_for_partition`]
937/// (structural) and [`resolve_rows_db_entry`] (to recover the inline raw key
938/// and the partition's Data.db position). A truncated `Rows.db` makes the
939/// referenced offset point past EOF or the row-trie read hit EOF.
940/// * A `DataOffset` payload carries the partition's decompressed-Data.db
941/// position directly; its raw key is resolved later through the Data.db scan.
942fn check_bti_structure(
943 dir: &Path,
944 components: &ComponentSet,
945 findings: &mut Vec<VerifyFinding>,
946) -> Result<Option<Vec<BtiResolvedLeaf>>> {
947 use crate::storage::sstable::bti::parser::{
948 iterate_partitions_in_bti_file, iterate_rows_for_partition, resolve_rows_db_entry,
949 BtiPartitionLocation,
950 };
951 use std::io::Cursor;
952
953 // --- Partitions.db ---------------------------------------------------
954 let partitions_path = components.path(dir, "Partitions.db");
955 let partitions_bytes = match std::fs::read(&partitions_path) {
956 Ok(b) => b,
957 Err(e) => {
958 findings.push(VerifyFinding::new(
959 VerifyErrorClass::MissingComponent,
960 "Partitions.db",
961 format!("cannot read Partitions.db: {}", e),
962 ));
963 return Ok(None);
964 }
965 };
966
967 // A BTI Partitions.db always ends with an 8-byte trailing root pointer; a
968 // file shorter than that is truncated/corrupt, NOT a valid empty trie.
969 // Without this, QUICK mode would report success for a truncated required
970 // index component (roborev).
971 if partitions_bytes.len() < 8 {
972 findings.push(VerifyFinding::new(
973 VerifyErrorClass::UnexpectedEof,
974 "Partitions.db",
975 format!(
976 "Partitions.db is {} bytes — shorter than the mandatory 8-byte trie root footer (truncated)",
977 partitions_bytes.len()
978 ),
979 ));
980 return Ok(None);
981 }
982
983 let mut cursor = Cursor::new(&partitions_bytes);
984 let partitions = match iterate_partitions_in_bti_file(&mut cursor) {
985 Ok(p) => p,
986 Err(e) => {
987 findings.push(VerifyFinding::new(
988 VerifyErrorClass::BtiRootPointerCorrupt,
989 "Partitions.db",
990 format!(
991 "Partitions.db trie walk failed (corrupt root pointer / node): {}",
992 e
993 ),
994 ));
995 return Ok(None);
996 }
997 };
998
999 if partitions.is_empty() {
1000 findings.push(VerifyFinding::new(
1001 VerifyErrorClass::BtiRootPointerCorrupt,
1002 "Partitions.db",
1003 format!(
1004 "Partitions.db ({} bytes) yielded zero partition keys — the root pointer is corrupt",
1005 partitions_bytes.len()
1006 ),
1007 ));
1008 return Ok(None);
1009 }
1010
1011 // --- Rows.db (per-partition row-index resolution) --------------------
1012 let rows_path = components.path(dir, "Rows.db");
1013 let rows_bytes = match std::fs::read(&rows_path) {
1014 Ok(b) => b,
1015 Err(e) => {
1016 findings.push(VerifyFinding::new(
1017 VerifyErrorClass::MissingComponent,
1018 "Rows.db",
1019 format!("cannot read Rows.db: {}", e),
1020 ));
1021 // Rows.db is gone, so `RowsOffset` payloads cannot be resolved; only
1022 // `DataOffset` leaves carry a self-contained position. Return what we
1023 // can (the missing-component finding already fails verification).
1024 let leaves = partitions
1025 .into_iter()
1026 .filter_map(|(prefix, location)| match location {
1027 BtiPartitionLocation::DataOffset(off) => Some(BtiResolvedLeaf {
1028 prefix,
1029 inline_raw_key: None,
1030 data_position: off,
1031 }),
1032 BtiPartitionLocation::RowsOffset(_) => None,
1033 })
1034 .collect();
1035 return Ok(Some(leaves));
1036 }
1037 };
1038
1039 // Resolve every leaf's PAYLOAD back to a raw partition key (issue #1103). A
1040 // `RowsOffset` leaf stores the raw key INLINE in `Rows.db` as
1041 // `[u16 key_length][key bytes]` at the offset (see `resolve_rows_db_entry`),
1042 // so we extract it directly — no Data.db read. A `DataOffset` leaf carries the
1043 // partition's decompressed-Data.db position directly; its raw key is resolved
1044 // later through the Data.db scan's position map.
1045 let mut leaves: Vec<BtiResolvedLeaf> = Vec::with_capacity(partitions.len());
1046 for (prefix, location) in partitions {
1047 match location {
1048 BtiPartitionLocation::RowsOffset(off) => {
1049 let off = off as usize;
1050 if off + 2 > rows_bytes.len() {
1051 findings.push(VerifyFinding::new(
1052 VerifyErrorClass::BtiTrieCorrupt,
1053 "Rows.db",
1054 format!(
1055 "partition (trie prefix {} bytes) references Rows.db offset {} which is past EOF ({} bytes) — Rows.db is truncated/corrupt",
1056 prefix.len(),
1057 off,
1058 rows_bytes.len()
1059 ),
1060 ));
1061 continue;
1062 }
1063 if let Err(e) = iterate_rows_for_partition(&rows_bytes, off) {
1064 findings.push(VerifyFinding::new(
1065 VerifyErrorClass::BtiTrieCorrupt,
1066 "Rows.db",
1067 format!(
1068 "row-index trie for partition at Rows.db offset {} failed to parse (truncated/corrupt): {}",
1069 off, e
1070 ),
1071 ));
1072 continue;
1073 }
1074
1075 // Inline raw partition key: [u16 key_length][key bytes] at `off`.
1076 let key_length =
1077 u16::from_be_bytes([rows_bytes[off], rows_bytes[off + 1]]) as usize;
1078 let key_start = off + 2;
1079 let key_end = key_start + key_length;
1080 if key_end > rows_bytes.len() {
1081 findings.push(VerifyFinding::new(
1082 VerifyErrorClass::BtiTrieCorrupt,
1083 "Rows.db",
1084 format!(
1085 "Rows.db entry at offset {} declares an inline key length {} that overruns the file ({} bytes)",
1086 off, key_length, rows_bytes.len()
1087 ),
1088 ));
1089 continue;
1090 }
1091 let inline_raw_key = rows_bytes[key_start..key_end].to_vec();
1092
1093 // Recover the partition's Data.db position too, so a leaf whose
1094 // INLINE key and Data.db position disagree (a payload tamper) is
1095 // still cross-checkable through the position map.
1096 let data_position = match resolve_rows_db_entry(&rows_bytes, off) {
1097 Ok(hdr) => hdr.data_position,
1098 Err(e) => {
1099 findings.push(VerifyFinding::new(
1100 VerifyErrorClass::BtiTrieCorrupt,
1101 "Rows.db",
1102 format!(
1103 "Rows.db entry at offset {} failed to deserialize (truncated/corrupt): {}",
1104 off, e
1105 ),
1106 ));
1107 continue;
1108 }
1109 };
1110
1111 leaves.push(BtiResolvedLeaf {
1112 prefix,
1113 inline_raw_key: Some(inline_raw_key),
1114 data_position,
1115 });
1116 }
1117 BtiPartitionLocation::DataOffset(off) => {
1118 leaves.push(BtiResolvedLeaf {
1119 prefix,
1120 inline_raw_key: None,
1121 data_position: off,
1122 });
1123 }
1124 }
1125 }
1126
1127 // Return the resolved leaves. FULL-mode verification cross-checks each leaf's
1128 // resolved raw partition key against the keys decoded from Data.db, by
1129 // IDENTITY (issue #1103).
1130 Ok(Some(leaves))
1131}
1132
1133/// Check 4 (BIG): structurally validate `Index.db`.
1134///
1135/// The production read path (`index_reader::parse_all_partition_keys_with_summary`)
1136/// stops at the first entry that fails to parse and returns the partitions
1137/// parsed so far — so a bit-flipped entry silently truncates (possibly to zero)
1138/// the partition list without any error. Here we walk every BIG index entry and
1139/// treat **either** a mid-stream parse error **or** leftover trailing bytes
1140/// **or** a zero-entry result on a non-empty file as corruption. This is what
1141/// prevents a corrupt Index.db from being reported as a healthy zero-row scan.
1142fn check_big_index(
1143 dir: &Path,
1144 components: &ComponentSet,
1145 findings: &mut Vec<VerifyFinding>,
1146) -> Result<()> {
1147 use crate::storage::sstable::index_reader::parse_big_index_entry;
1148
1149 let index_path = components.path(dir, "Index.db");
1150 if !index_path.exists() {
1151 // Absence is surfaced by the TOC check (Index.db is critical for BIG);
1152 // record it explicitly so the index check is never silently skipped.
1153 findings.push(VerifyFinding::new(
1154 VerifyErrorClass::MissingComponent,
1155 "Index.db",
1156 format!(
1157 "BIG-format Index.db not present at {}",
1158 index_path.display()
1159 ),
1160 ));
1161 return Ok(());
1162 }
1163
1164 let bytes = std::fs::read(&index_path).map_err(|e| {
1165 Error::corruption(format!(
1166 "Cannot read Index.db at {}: {}",
1167 index_path.display(),
1168 e
1169 ))
1170 })?;
1171
1172 if bytes.is_empty() {
1173 findings.push(VerifyFinding::new(
1174 VerifyErrorClass::IndexEntryCorrupt,
1175 "Index.db",
1176 "Index.db is empty (no partition entries)".to_string(),
1177 ));
1178 return Ok(());
1179 }
1180
1181 let total = bytes.len();
1182 let mut remaining: &[u8] = &bytes;
1183 let mut entry_index = 0usize;
1184 loop {
1185 if remaining.is_empty() {
1186 break;
1187 }
1188 let consumed_before = total - remaining.len();
1189 match parse_big_index_entry(remaining) {
1190 Ok((rest, _entry)) => {
1191 if rest.len() >= remaining.len() {
1192 // No forward progress -> structurally broken.
1193 findings.push(VerifyFinding::new(
1194 VerifyErrorClass::IndexEntryCorrupt,
1195 "Index.db",
1196 format!(
1197 "Index.db entry {} at byte offset {} made no forward progress (corrupt length field)",
1198 entry_index, consumed_before
1199 ),
1200 ));
1201 return Ok(());
1202 }
1203 remaining = rest;
1204 entry_index += 1;
1205 }
1206 Err(e) => {
1207 findings.push(VerifyFinding::new(
1208 VerifyErrorClass::IndexEntryCorrupt,
1209 "Index.db",
1210 format!(
1211 "Index.db entry {} at byte offset {} failed to parse ({} of {} bytes consumed): {:?}",
1212 entry_index, consumed_before, consumed_before, total, e
1213 ),
1214 ));
1215 return Ok(());
1216 }
1217 }
1218 }
1219
1220 if entry_index == 0 {
1221 findings.push(VerifyFinding::new(
1222 VerifyErrorClass::IndexEntryCorrupt,
1223 "Index.db",
1224 format!(
1225 "Index.db parsed zero partition entries from {} bytes",
1226 total
1227 ),
1228 ));
1229 }
1230
1231 Ok(())
1232}
1233
1234/// Check 6b (FULL, BIG): `Summary.db` parses.
1235async fn check_summary(
1236 dir: &Path,
1237 components: &ComponentSet,
1238 platform: Arc<Platform>,
1239 findings: &mut Vec<VerifyFinding>,
1240) {
1241 use crate::storage::sstable::summary_reader::SummaryReader;
1242
1243 let summary_path = components.path(dir, "Summary.db");
1244 if !summary_path.exists() {
1245 return; // absence covered by TOC check if listed
1246 }
1247 if let Err(e) = SummaryReader::open(&summary_path, platform).await {
1248 findings.push(VerifyFinding::new(
1249 VerifyErrorClass::SummaryCorrupt,
1250 "Summary.db",
1251 format!("Summary.db failed to parse: {}", e),
1252 ));
1253 }
1254}
1255
1256/// Check 6c (FULL, BIG): the `Filter.db` Bloom filter must have NO false
1257/// negatives over the present partition keys (issue #1398).
1258///
1259/// A false negative — `might_contain == false` for a key Cassandra actually wrote
1260/// — makes that partition silently invisible on the BIG point-lookup path
1261/// (`partition_lookup.rs` returns `Ok(None)` on a bloom "miss"). Because
1262/// `Filter.db` carries no checksum, a bit flipped 1→0 inside the bit array is not
1263/// caught on load; only re-probing every present key against the decoded filter
1264/// surfaces it. The authoritative present-key set is the raw partition-key bytes
1265/// in the sibling `Index.db` (`key_digest`, issue #552) — exactly the bytes
1266/// Cassandra's Murmur3 hashed into the filter (no path/type heuristics).
1267///
1268/// Fail-open, safe direction (matches `component_loading.rs`): if `Filter.db` is
1269/// absent or does not decode, this check records nothing — an absent/unparseable
1270/// filter means the read path simply skips the bloom (no false negatives). Only a
1271/// PARSEABLE filter that drops a present key is flagged. If `Index.db` is
1272/// absent/corrupt the present-key set is unavailable, so nothing is probed here
1273/// (that corruption is surfaced by [`check_big_index`]).
1274async fn check_filter_false_negatives(
1275 dir: &Path,
1276 components: &ComponentSet,
1277 platform: Arc<Platform>,
1278 findings: &mut Vec<VerifyFinding>,
1279) {
1280 use crate::storage::sstable::bloom::BloomFilter;
1281 use crate::storage::sstable::index_reader::IndexReader;
1282
1283 let filter_path = components.path(dir, "Filter.db");
1284 let index_path = components.path(dir, "Index.db");
1285 // Absent Filter.db → the read path skips the bloom entirely (no false
1286 // negatives possible). Absent Index.db → no authoritative present-key source.
1287 if !filter_path.exists() || !index_path.exists() {
1288 return;
1289 }
1290
1291 let Ok(filter_bytes) = std::fs::read(&filter_path) else {
1292 return;
1293 };
1294 // Fail-open: an unparseable filter is the safe direction (component_loading.rs
1295 // makes the bloom simply unavailable). Only a PARSEABLE-but-wrong filter is the
1296 // silent false-negative hazard this check exists to catch.
1297 let Ok(bloom) = BloomFilter::deserialize(&filter_bytes) else {
1298 return;
1299 };
1300
1301 // Enumerate the authoritative present keys from Index.db. A parse failure here
1302 // is Index.db corruption, already surfaced by check_big_index — do not
1303 // fabricate a filter finding from it.
1304 let Ok(reader) = IndexReader::open(&index_path, platform).await else {
1305 return;
1306 };
1307
1308 let mut present = 0usize;
1309 let mut false_negatives = 0usize;
1310 for entry in reader.get_partition_entries() {
1311 present += 1;
1312 if !bloom.might_contain(&entry.key_digest) {
1313 false_negatives += 1;
1314 }
1315 }
1316
1317 if false_negatives > 0 {
1318 findings.push(VerifyFinding::new(
1319 VerifyErrorClass::FilterFalseNegative,
1320 "Filter.db",
1321 format!(
1322 "Bloom filter reported {false_negatives} false negative(s) over {present} present \
1323 partition key(s): a present key hashes to a bit the filter reports unset, so the \
1324 BIG point-lookup path would return no rows for a live partition (silent data \
1325 invisibility). Filter.db carries no checksum, so a 1→0 bit flip inside the bit \
1326 array is not caught on load; full scans and BTI lookups are unaffected."
1327 ),
1328 ));
1329 }
1330}
1331
1332/// Check 5 (FULL): validate every inline `Data.db` chunk CRC32 (#998) and that
1333/// each chunk decompresses. Uses the [`ChunkDecompressor`] stitch path so this
1334/// exercises real LZ4/Snappy/Deflate/Zstd decoding.
1335fn check_inline_chunk_crc(
1336 components: &ComponentSet,
1337 info: &CompressionInfo,
1338 findings: &mut Vec<VerifyFinding>,
1339) -> Result<()> {
1340 use crate::storage::sstable::chunk_reader::ChunkReader;
1341 use std::fs::File;
1342
1343 let file = match File::open(&components.data_path) {
1344 Ok(f) => f,
1345 Err(e) => {
1346 findings.push(VerifyFinding::new(
1347 VerifyErrorClass::MissingComponent,
1348 "Data.db",
1349 format!("cannot open Data.db for chunk-CRC check: {}", e),
1350 ));
1351 return Ok(());
1352 }
1353 };
1354 let total_size = match file.metadata() {
1355 Ok(m) => m.len(),
1356 Err(e) => {
1357 findings.push(VerifyFinding::new(
1358 VerifyErrorClass::MissingComponent,
1359 "Data.db",
1360 format!("cannot stat Data.db for chunk-CRC check: {}", e),
1361 ));
1362 return Ok(());
1363 }
1364 };
1365 let reader = std::io::BufReader::new(file);
1366
1367 // ChunkReader validates ONLY the inline 4-byte CRC32 of each chunk (#998)
1368 // without decompressing it. This is the precise integrity guarantee we want
1369 // here: a bit-flip inside a chunk payload fails the CRC, and a truncated
1370 // file fails the chunk read with EOF. Decode correctness is covered
1371 // separately by the full row scan (Check 7), so we deliberately do NOT
1372 // re-decompress here (that would false-positive on the last/incompressible
1373 // chunk's size bookkeeping for some BTI Data.db files).
1374 let mut chunk_reader = ChunkReader::new(reader, info.clone(), total_size);
1375 if let Err(e) = chunk_reader.read_all_chunks() {
1376 findings.push(classify_data_error("Data.db", &e));
1377 }
1378 Ok(())
1379}
1380
1381/// Check 5b (FULL, uncompressed BIG only): read `CRC.db` and validate every
1382/// uncompressed `Data.db` chunk against its stored per-chunk CRC32 (issue #1396).
1383///
1384/// This is the uncompressed analogue of [`check_inline_chunk_crc`]. Cassandra
1385/// writes a `CRC.db` for every uncompressed BIG SSTable; a bit flip inside a
1386/// chunk (or a truncated `CRC.db`) is reported as an
1387/// [`VerifyErrorClass::UncompressedChunkCrcMismatch`] `VerifyFinding` naming the
1388/// failing chunk. Streams the Data.db one `chunk_size` block at a time (bounded
1389/// memory) rather than buffering the whole file. An absent `CRC.db` is the
1390/// owner-pinned warn-and-proceed decision (design D4): no finding is recorded
1391/// (its absence is surfaced by the TOC/presence check when listed).
1392async fn check_uncompressed_crc_db(
1393 dir: &Path,
1394 components: &ComponentSet,
1395 findings: &mut Vec<VerifyFinding>,
1396) {
1397 use crate::storage::sstable::reader::crc::CrcDb;
1398 use tokio::io::AsyncReadExt;
1399
1400 let crc_path = components.path(dir, "CRC.db");
1401 if !crc_path.exists() {
1402 // Absent CRC.db: warn-and-proceed (design D4). Not a checksum-mismatch.
1403 return;
1404 }
1405
1406 // Data.db length bounds the maximum plausible CRC.db size (issue #1396
1407 // Fix 2): `CrcDb::open` rejects an oversized sidecar before reading its body.
1408 let data_len = tokio::fs::metadata(&components.data_path)
1409 .await
1410 .map(|m| m.len())
1411 .unwrap_or(0);
1412 let crc = match CrcDb::open(&crc_path, data_len).await {
1413 Ok(c) => c,
1414 Err(e) => {
1415 findings.push(VerifyFinding::new(
1416 VerifyErrorClass::UncompressedChunkCrcMismatch,
1417 "CRC.db",
1418 format!("CRC.db failed to parse: {e}"),
1419 ));
1420 return;
1421 }
1422 };
1423
1424 let chunk_size = crc.chunk_size() as usize;
1425 let mut file = match tokio::fs::File::open(&components.data_path).await {
1426 Ok(f) => f,
1427 Err(e) => {
1428 findings.push(VerifyFinding::new(
1429 VerifyErrorClass::MissingComponent,
1430 "Data.db",
1431 format!("cannot open Data.db for CRC.db check: {e}"),
1432 ));
1433 return;
1434 }
1435 };
1436
1437 // Walk Data.db one chunk_size block at a time and compare each block's CRC32
1438 // to the stored value (bounded memory, O(chunk_size)).
1439 let mut chunk_index = 0usize;
1440 let mut offset: u64 = 0;
1441 // `chunk_size` is bounded by `MAX_CRC_CHUNK_SIZE` at parse time
1442 // (`CrcDb::parse`, issue #1396) — a malformed sidecar advertising an absurd
1443 // size was already rejected above as typed corruption, so this scratch
1444 // allocation can never scale to an OOM.
1445 let mut buf = vec![0u8; chunk_size];
1446 loop {
1447 let mut filled = 0usize;
1448 // Accumulate a full chunk (or the short final chunk at EOF).
1449 loop {
1450 match file.read(&mut buf[filled..]).await {
1451 Ok(0) => break,
1452 Ok(n) => {
1453 filled += n;
1454 if filled == chunk_size {
1455 break;
1456 }
1457 }
1458 Err(e) => {
1459 findings.push(VerifyFinding::new(
1460 VerifyErrorClass::UncompressedChunkCrcMismatch,
1461 "Data.db",
1462 format!("read error verifying chunk {chunk_index} against CRC.db: {e}"),
1463 ));
1464 return;
1465 }
1466 }
1467 }
1468 if filled == 0 {
1469 break; // clean EOF on a chunk boundary
1470 }
1471 let computed = crc32fast::hash(&buf[..filled]);
1472 match crc.crc_for_chunk(chunk_index) {
1473 Ok(expected) => {
1474 if computed != expected {
1475 findings.push(VerifyFinding::new(
1476 VerifyErrorClass::UncompressedChunkCrcMismatch,
1477 "Data.db",
1478 format!(
1479 "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}"
1480 ),
1481 ));
1482 // Report the first failing chunk and stop (matches the
1483 // fail-fast read-path posture; naming one chunk is sufficient).
1484 return;
1485 }
1486 }
1487 Err(e) => {
1488 findings.push(VerifyFinding::new(
1489 VerifyErrorClass::UncompressedChunkCrcMismatch,
1490 "CRC.db",
1491 format!("CRC.db has no entry for Data.db chunk {chunk_index} (truncated): {e}"),
1492 ));
1493 return;
1494 }
1495 }
1496 offset += filled as u64;
1497 chunk_index += 1;
1498 if filled < chunk_size {
1499 break; // short final chunk consumed
1500 }
1501 }
1502}
1503
1504/// Check 6 (FULL): `Statistics.db` parses. Records a finding on failure but
1505/// never aborts the rest of verification.
1506async fn check_statistics(
1507 dir: &Path,
1508 components: &ComponentSet,
1509 platform: Arc<Platform>,
1510 findings: &mut Vec<VerifyFinding>,
1511) {
1512 use crate::storage::sstable::statistics_reader::StatisticsReader;
1513
1514 let stats_path = components.path(dir, "Statistics.db");
1515 if !stats_path.exists() {
1516 return; // absence already covered by the TOC check if listed
1517 }
1518
1519 // Direct TOC-header sanity check FIRST. Cassandra's `MetadataSerializer`
1520 // writes Statistics.db as: [u32 BE num_components][u32 BE checksum][TOC...].
1521 // The production `StatisticsReader` is intentionally lenient (it falls back
1522 // through several parsers and can silently accept a damaged header), so we
1523 // validate the authoritative component count here. Cassandra only ever
1524 // emits 4 metadata components (VALIDATION/COMPACTION/STATS/HEADER); a count
1525 // outside [1,100] means the header is corrupt (e.g. the high byte flipped
1526 // to 0xFF -> ~4.28e9 components).
1527 match std::fs::read(&stats_path) {
1528 Ok(bytes) if bytes.len() >= 8 => {
1529 let num_components = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
1530 if num_components == 0 || num_components > 100 {
1531 findings.push(VerifyFinding::new(
1532 VerifyErrorClass::StatisticsHeaderCorrupt,
1533 "Statistics.db",
1534 format!(
1535 "Statistics.db TOC header is corrupt: num_components={} at byte 0 (expected 1..=100; first 4 bytes {:02x} {:02x} {:02x} {:02x})",
1536 num_components, bytes[0], bytes[1], bytes[2], bytes[3]
1537 ),
1538 ));
1539 return;
1540 }
1541 }
1542 Ok(bytes) => {
1543 findings.push(VerifyFinding::new(
1544 VerifyErrorClass::StatisticsHeaderCorrupt,
1545 "Statistics.db",
1546 format!(
1547 "Statistics.db is {} bytes — too small for the 8-byte TOC header",
1548 bytes.len()
1549 ),
1550 ));
1551 return;
1552 }
1553 Err(e) => {
1554 findings.push(VerifyFinding::new(
1555 VerifyErrorClass::StatisticsHeaderCorrupt,
1556 "Statistics.db",
1557 format!("cannot read Statistics.db: {}", e),
1558 ));
1559 return;
1560 }
1561 }
1562
1563 if let Err(e) = StatisticsReader::open(&stats_path, platform).await {
1564 findings.push(VerifyFinding::new(
1565 VerifyErrorClass::StatisticsHeaderCorrupt,
1566 "Statistics.db",
1567 format!("Statistics.db failed to parse: {}", e),
1568 ));
1569 }
1570}
1571
1572/// Check 7 (FULL): a complete row scan. Returns `(rows, distinct_partitions)`
1573/// where `distinct_partitions` is the set of distinct partition keys decoded
1574/// from `Data.db`, each paired with its decompressed-Data.db partition-start
1575/// position (used for the BTI Partitions.db identity cross-check, issue #1103).
1576async fn full_row_scan_partitions(
1577 data_path: &Path,
1578 config: &Config,
1579 platform: Arc<Platform>,
1580) -> Result<(usize, Vec<(u64, Vec<u8>)>)> {
1581 let reader = SSTableReader::open(data_path, config, platform).await?;
1582
1583 // `rows` is the total decoded row/entry count (exercises the full
1584 // decompression + decode stitch path so Data.db corruption surfaces here).
1585 let entries = reader.get_all_entries().await?;
1586 let rows = entries.len();
1587
1588 // `distinct_partition_keys_with_positions` are the raw serialized PARTITION
1589 // keys decoded from Data.db — one per partition, NOT per row — each tagged
1590 // with its decompressed-Data.db partition-start position. Deduping
1591 // `get_all_entries` RowKeys would over-count a multi-row partition (those keys
1592 // carry clustering/column/static suffixes), which previously FALSE-FAILED the
1593 // BTI Partitions.db cross-check on healthy SSTables (issue #970). The reader
1594 // dedups at the partition boundary for both BIG (`nb`) and BTI (`da`); the
1595 // position lets the verifier resolve a BTI leaf's payload back to its raw key.
1596 let partitions = reader.distinct_partition_keys_with_positions().await?;
1597
1598 Ok((rows, partitions))
1599}
1600
1601/// Cross-check BTI `Partitions.db` leaves against the partitions decoded from
1602/// `Data.db` by IDENTITY (issue #1103). Returns `Some(detail)` describing the
1603/// mismatch when the trie does not represent the same partition set as Data.db,
1604/// or `None` when they agree.
1605///
1606/// Unlike a prefix-only compare (which only looks at the leaf's emitted
1607/// byte-comparable transition bytes), this resolves each leaf's PAYLOAD back to a
1608/// raw partition key using authoritative data and matches it against the Data.db
1609/// keys. This closes a same-count, wrong-IDENTITY corruption that keeps the
1610/// emitted prefix but rewrites the payload (`DataOffset` / `RowsOffset` →
1611/// `data_position`) to point at a DIFFERENT partition:
1612///
1613/// * `RowsOffset` leaf: the raw key is stored INLINE in `Rows.db`
1614/// ([`BtiResolvedLeaf::inline_raw_key`]); matched directly against Data.db.
1615/// * `DataOffset` leaf: matched via its decompressed-Data.db
1616/// [`BtiResolvedLeaf::data_position`], looked up in the Data.db scan's
1617/// `position → raw_key` map.
1618///
1619/// We require an exact MULTISET equality between the resolved leaf keys and the
1620/// Data.db keys, plus a per-leaf consistency check that the resolved raw key's
1621/// byte-comparable encoding actually starts with the leaf's emitted prefix
1622/// (catches a leaf whose path is inconsistent with its payload).
1623///
1624/// Note: the byte-comparable encoding assumes `Murmur3Partitioner`, matching the
1625/// rest of CQLite's BTI read path (issue #755).
1626fn bti_partition_identity_mismatch(
1627 leaves: &[BtiResolvedLeaf],
1628 data_partitions: &[(u64, Vec<u8>)],
1629) -> Option<String> {
1630 use crate::storage::sstable::bti::parser::encode_partition_key_for_bti_trie;
1631 use std::collections::HashMap;
1632
1633 let hex = |b: &[u8]| b.iter().map(|x| format!("{:02x}", x)).collect::<String>();
1634
1635 // Data.db side: a position → raw_key map (to resolve `DataOffset` leaves) plus
1636 // the raw-key multiset (to compare identities).
1637 let pos_to_key: HashMap<u64, &Vec<u8>> = data_partitions.iter().map(|(p, k)| (*p, k)).collect();
1638
1639 // Resolve every leaf to a raw partition key.
1640 let mut leaf_keys: Vec<Vec<u8>> = Vec::with_capacity(leaves.len());
1641 for leaf in leaves {
1642 let raw_key = match &leaf.inline_raw_key {
1643 // `RowsOffset` leaf: authoritative inline key. Its recorded Data.db
1644 // position MUST resolve to a decoded partition start carrying the SAME
1645 // raw key. A position that maps to a different key is a desync; a
1646 // position that maps to NOTHING means the `Rows.db` entry's
1647 // `data_position` is corrupt — a BTI read would seek to a non-partition
1648 // offset in Data.db even though the inline key looks valid, so it is
1649 // just as fatal as a corrupt `DataOffset` payload.
1650 Some(inline) => match pos_to_key.get(&leaf.data_position) {
1651 Some(by_pos) => {
1652 if by_pos.as_slice() != inline.as_slice() {
1653 return Some(format!(
1654 "Partitions.db leaf (prefix {}) inline raw key {} disagrees with the key at its Data.db position {} ({}) — the leaf payload was tampered",
1655 hex(&leaf.prefix),
1656 hex(inline),
1657 leaf.data_position,
1658 hex(by_pos),
1659 ));
1660 }
1661 inline.clone()
1662 }
1663 None => {
1664 return Some(format!(
1665 "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)",
1666 hex(&leaf.prefix),
1667 hex(inline),
1668 leaf.data_position,
1669 ));
1670 }
1671 },
1672 // `DataOffset` leaf: resolve via the Data.db position map. A payload
1673 // flipped to a position that is not a partition start matches nothing.
1674 None => match pos_to_key.get(&leaf.data_position) {
1675 Some(k) => (*k).clone(),
1676 None => {
1677 return Some(format!(
1678 "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)",
1679 hex(&leaf.prefix),
1680 leaf.data_position,
1681 ));
1682 }
1683 },
1684 };
1685
1686 // Per-leaf path/payload consistency: the resolved raw key's
1687 // byte-comparable encoding MUST start with the leaf's emitted prefix.
1688 let encoded = encode_partition_key_for_bti_trie(&raw_key);
1689 if !encoded.starts_with(leaf.prefix.as_slice()) {
1690 return Some(format!(
1691 "Partitions.db leaf prefix {} is inconsistent with its payload's partition key (encodes to {}) — the trie path does not match the leaf payload",
1692 hex(&leaf.prefix),
1693 hex(&encoded),
1694 ));
1695 }
1696
1697 leaf_keys.push(raw_key);
1698 }
1699
1700 // Exact MULTISET equality between the resolved leaf keys and the Data.db keys.
1701 let mut data_counts: HashMap<&[u8], i64> = HashMap::new();
1702 for (_, k) in data_partitions {
1703 *data_counts.entry(k.as_slice()).or_insert(0) += 1;
1704 }
1705 let mut leaf_counts: HashMap<&[u8], i64> = HashMap::new();
1706 for k in &leaf_keys {
1707 *leaf_counts.entry(k.as_slice()).or_insert(0) += 1;
1708 }
1709
1710 if leaf_keys.len() != data_partitions.len() {
1711 return Some(format!(
1712 "Partitions.db trie yielded {} partition keys but Data.db decoded {} distinct partitions — the trie was walked from a corrupt root",
1713 leaf_keys.len(),
1714 data_partitions.len()
1715 ));
1716 }
1717
1718 for (k, &lc) in &leaf_counts {
1719 let dc = data_counts.get(k).copied().unwrap_or(0);
1720 if lc != dc {
1721 return Some(format!(
1722 "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)",
1723 hex(k),
1724 lc,
1725 dc,
1726 ));
1727 }
1728 }
1729 for (k, &dc) in &data_counts {
1730 let lc = leaf_counts.get(k).copied().unwrap_or(0);
1731 if lc != dc {
1732 return Some(format!(
1733 "Data.db partition key {} appears {} time(s) but Partitions.db resolves it {} time(s) — the trie does not match Data.db identities",
1734 hex(k),
1735 dc,
1736 lc,
1737 ));
1738 }
1739 }
1740
1741 None
1742}
1743
1744/// Check 8 (FULL): partition key/row ordering + partition-level
1745/// `localDeletionTime` validity (issue #1282).
1746///
1747/// Two corruption classes Cassandra's `sstableverify` rejects that the earlier
1748/// checks did not classify:
1749///
1750/// * **Out-of-order key/row** ([`VerifyErrorClass::OutOfOrderKeyOrRow`]).
1751/// Cassandra stores partitions in ascending **Murmur3 token** order (ties
1752/// broken by the raw key bytes). We recompute each partition's token with the
1753/// authoritative [`cassandra_murmur3_token`] (Murmur3Partitioner, matching the
1754/// rest of CQLite's BTI read path, issue #755) and flag the first
1755/// `(token, key)` pair that is not strictly greater than its predecessor.
1756///
1757/// * **Invalid partition-level local-deletion-time**
1758/// ([`VerifyErrorClass::InvalidLocalDeletionTime`]). `localDeletionTime` is
1759/// seconds since the Unix epoch; the only special non-negative value is the
1760/// live sentinel `i32::MAX`. On the legacy signed (`nb`) `DeletionTime` form a
1761/// NEGATIVE partition-level value is unambiguously corrupt (Cassandra's
1762/// `DeletionTime`/`Verifier` rejects it). The unsigned `oa`/`da` form
1763/// legitimately represents far-future times in `[2^31, 2^32)` as a negative
1764/// `i32`, so we ONLY flag a negative value when the on-disk format is the
1765/// signed legacy form — the format, not a heuristic, decides.
1766///
1767/// Both facts come from the SAME authoritative partition-header decode the scan
1768/// already performs (see [`SSTableReader::partition_verify_scan`]); this is not a
1769/// second guessing pass. Environmental errors (reader open) are surfaced through
1770/// the existing scan-error classifier rather than aborting verification.
1771async fn check_key_order_and_ldt(
1772 data_path: &Path,
1773 config: &Config,
1774 platform: Arc<Platform>,
1775 findings: &mut Vec<VerifyFinding>,
1776) {
1777 let reader = match SSTableReader::open(data_path, config, platform).await {
1778 Ok(r) => r,
1779 Err(_) => {
1780 // A reader-open failure here is already surfaced by the Check 7 scan
1781 // (it opens the same reader first); do not double-report it.
1782 return;
1783 }
1784 };
1785 let signed_ldt = !reader.has_uint_deletion_time();
1786 let partitions = match reader.partition_verify_scan().await {
1787 Ok(p) => p,
1788 Err(_) => {
1789 // A parse failure is Check 7's territory (RowScanFailed / decode);
1790 // avoid a duplicate, differently-classed finding for the same cause.
1791 return;
1792 }
1793 };
1794
1795 findings.extend(classify_order_and_ldt(&partitions, signed_ldt));
1796
1797 // Row-order half of OutOfOrderKeyOrRow (issue #1282 roborev follow-up):
1798 // Cassandra's Verifier also rejects out-of-order CLUSTERING rows within a
1799 // partition. Decode each partition's clustering rows in on-disk order and flag
1800 // a non-increasing clustering step using the authoritative schema comparator
1801 // (which respects reversed/DESC clustering order). A table with no clustering
1802 // columns yields an empty scan and produces no findings.
1803 if let Some(schema) = reader.effective_schema() {
1804 // A decode failure is Check 7's territory; do not double-report. Only a
1805 // successful scan feeds the clustering-order classifier.
1806 if !schema.clustering_keys.is_empty() {
1807 if let Ok(partition_rows) = reader.partition_clustering_verify_scan().await {
1808 findings.extend(classify_clustering_row_order(&partition_rows, &schema));
1809 }
1810 }
1811 }
1812}
1813
1814/// Compare two clustering-key tuples in the authoritative schema clustering
1815/// order (issue #1282 roborev follow-up).
1816///
1817/// Each column is compared with its non-gated [`ComparatorType`] (derived from the
1818/// schema clustering type) and the result reversed for a DESC column, mirroring
1819/// Cassandra's reversed-type ordering. An absent trailing component (a shorter
1820/// tuple) is treated as NULL, which sorts first regardless of ASC/DESC — matching
1821/// `ClusteringKey::compare`. NO heuristics: the format-derived comparator and the
1822/// schema's ASC/DESC flag decide.
1823fn compare_clustering_tuples(
1824 a: &[crate::types::Value],
1825 b: &[crate::types::Value],
1826 schema: &crate::schema::TableSchema,
1827) -> Result<std::cmp::Ordering> {
1828 use crate::types::Value;
1829 use std::cmp::Ordering;
1830
1831 let comparators = schema.get_clustering_key_comparators()?;
1832 for (i, ck) in schema.clustering_keys.iter().enumerate() {
1833 let av = a.get(i).unwrap_or(&Value::Null);
1834 let bv = b.get(i).unwrap_or(&Value::Null);
1835 // NULL/absent component sorts first regardless of ASC/DESC (no reversal).
1836 let ord = match (av, bv) {
1837 (Value::Null, Value::Null) => Ordering::Equal,
1838 (Value::Null, _) => Ordering::Less,
1839 (_, Value::Null) => Ordering::Greater,
1840 (_, _) => {
1841 let cmp = comparators
1842 .get(i)
1843 .ok_or_else(|| {
1844 Error::Schema(format!(
1845 "missing clustering comparator for column {}",
1846 ck.name
1847 ))
1848 })?
1849 .compare(av, bv)?;
1850 if ck.order == crate::schema::ClusteringOrder::Desc {
1851 cmp.reverse()
1852 } else {
1853 cmp
1854 }
1855 }
1856 };
1857 if ord != Ordering::Equal {
1858 return Ok(ord);
1859 }
1860 }
1861 Ok(Ordering::Equal)
1862}
1863
1864/// Pure classifier for the ROW half of Check 8 (issue #1282 roborev follow-up):
1865/// given each partition's clustering-key tuples in on-disk order and the
1866/// authoritative schema, flag the first partition whose clustering rows are not in
1867/// strictly ascending schema order as [`VerifyErrorClass::OutOfOrderKeyOrRow`].
1868///
1869/// The comparison applies each clustering column's ASC/DESC order via
1870/// [`compare_clustering_tuples`] — NO heuristics. A non-increasing step (a row
1871/// equal to or before its predecessor) is corruption Cassandra's `Verifier`
1872/// rejects.
1873///
1874/// Kept side-effect-free so the public verify path and the unit tests drive the
1875/// EXACT same classification (wiring evidence: `check_key_order_and_ldt` calls
1876/// this, and `verify_sstable` calls that in FULL mode).
1877fn classify_clustering_row_order(
1878 partition_rows: &[(usize, Vec<Vec<crate::types::Value>>)],
1879 schema: &crate::schema::TableSchema,
1880) -> Vec<VerifyFinding> {
1881 use std::cmp::Ordering;
1882
1883 let mut findings = Vec::new();
1884 for (part_idx, rows) in partition_rows {
1885 for pair in rows.windows(2) {
1886 let (prev, cur) = (&pair[0], &pair[1]);
1887 // A comparator error (schema/type mismatch) is not an ordering fault;
1888 // Check 7 owns decode/type failures, so skip rather than misclassify.
1889 let ord = match compare_clustering_tuples(cur, prev, schema) {
1890 Ok(o) => o,
1891 Err(_) => continue,
1892 };
1893 // On disk a later clustering row MUST be strictly greater than its
1894 // predecessor; Equal or Less is out-of-order corruption.
1895 if ord != Ordering::Greater {
1896 findings.push(VerifyFinding::new(
1897 VerifyErrorClass::OutOfOrderKeyOrRow,
1898 "Data.db",
1899 format!(
1900 "partition {} has an out-of-order clustering row: {:?} is not strictly after the previous row {:?} in schema clustering order",
1901 part_idx, cur, prev,
1902 ),
1903 ));
1904 break;
1905 }
1906 }
1907 }
1908 findings
1909}
1910
1911/// Pure classifier for Check 8 (issue #1282): given the on-disk-ordered
1912/// `(raw_partition_key, partition_local_deletion_time)` list from
1913/// [`SSTableReader::partition_verify_scan`] and whether the on-disk
1914/// `DeletionTime` is the legacy SIGNED form, return any order / LDT findings.
1915///
1916/// Kept side-effect-free so both the public verify path and the unit tests drive
1917/// the EXACT same classification (wiring evidence: `check_key_order_and_ldt`
1918/// calls this, and `verify_sstable` calls that in FULL mode).
1919fn classify_order_and_ldt(
1920 partitions: &[(Vec<u8>, Option<i32>)],
1921 signed_ldt: bool,
1922) -> Vec<VerifyFinding> {
1923 use crate::util::cassandra_murmur3::cassandra_murmur3_token;
1924
1925 let mut findings = Vec::new();
1926 let hex = |b: &[u8]| b.iter().map(|x| format!("{:02x}", x)).collect::<String>();
1927
1928 // ---- Out-of-order partition keys (Murmur3 token order) -----------------
1929 let mut prev: Option<(i64, Vec<u8>)> = None;
1930 for (idx, (key, _ldt)) in partitions.iter().enumerate() {
1931 let token = cassandra_murmur3_token(key);
1932 if let Some((prev_token, prev_key)) = prev.as_ref() {
1933 // Cassandra orders by (token, key bytes). A later partition MUST be
1934 // strictly greater; equal or lesser is out-of-order corruption.
1935 let ordered = (*prev_token, prev_key.as_slice()) < (token, key.as_slice());
1936 if !ordered {
1937 findings.push(VerifyFinding::new(
1938 VerifyErrorClass::OutOfOrderKeyOrRow,
1939 "Data.db",
1940 format!(
1941 "partition {} (key {}, token {}) is not strictly after the previous partition (key {}, token {}) — partitions are stored out of Murmur3 token order",
1942 idx,
1943 hex(key),
1944 token,
1945 hex(prev_key),
1946 prev_token,
1947 ),
1948 ));
1949 break;
1950 }
1951 }
1952 prev = Some((token, key.clone()));
1953 }
1954
1955 // ---- Negative (invalid) partition-level localDeletionTime (nb) ---------
1956 if signed_ldt {
1957 for (key, ldt) in partitions {
1958 if let Some(ldt) = ldt {
1959 // A deleted partition's localDeletionTime is epoch-seconds; it
1960 // cannot be negative. (The live sentinel i32::MAX is positive and
1961 // is already resolved to `None` by the header parser.)
1962 if *ldt < 0 {
1963 findings.push(VerifyFinding::new(
1964 VerifyErrorClass::InvalidLocalDeletionTime,
1965 "Data.db",
1966 format!(
1967 "partition (key {}) has a negative localDeletionTime {} (0x{:08x}) on the signed (nb) DeletionTime form — a valid deletion time is >= 0 seconds since epoch",
1968 hex(key),
1969 ldt,
1970 *ldt as u32,
1971 ),
1972 ));
1973 break;
1974 }
1975 }
1976 }
1977 }
1978
1979 findings
1980}
1981
1982/// Map an error surfaced by the inline-CRC / decompression path onto a stable
1983/// error class, keyed by the message shape the lower layers produce.
1984fn classify_data_error(component: &str, err: &Error) -> VerifyFinding {
1985 let msg = err.to_string();
1986 // A truncated Data.db makes a chunk read hit EOF; a bit-flip makes the
1987 // inline CRC mismatch or the decompressor reject the payload. Everything
1988 // surfaced here is a Data.db chunk problem.
1989 let class = if msg.contains("Failed to read")
1990 || msg.contains("failed to fill whole buffer")
1991 || msg.contains("UnexpectedEof")
1992 || msg.contains("end of file")
1993 {
1994 VerifyErrorClass::UnexpectedEof
1995 } else {
1996 VerifyErrorClass::ChunkDecompressionError
1997 };
1998 VerifyFinding::new(class, component.to_string(), msg)
1999}
2000
2001/// Map an error surfaced by the full-scan path onto a stable error class. The
2002/// scan touches Data.db (and, for BIG, Index.db); the structural checks have
2003/// already classified index/BTI corruption, so anything here is a Data.db /
2004/// decode failure.
2005fn classify_scan_error(components: &ComponentSet, err: &Error) -> VerifyFinding {
2006 let _ = components; // index/BTI corruption is classified earlier; this is Data.db decode
2007 let class = classify_scan_error_class(err);
2008 VerifyFinding::new(class, "Data.db".to_string(), err.to_string())
2009}
2010
2011/// Map a Data.db scan/decode error to its stable [`VerifyErrorClass`].
2012///
2013/// Split out from [`classify_scan_error`] so the classification is unit-testable
2014/// without constructing a [`ComponentSet`] (which the classifier ignores).
2015fn classify_scan_error_class(err: &Error) -> VerifyErrorClass {
2016 let msg = err.to_string();
2017 let lower = msg.to_lowercase();
2018 // Unsupported compression FEATURE (issue #1414): the reader fails closed with
2019 // `Error::UnsupportedFormat` on a valid-but-unimplemented compression feature
2020 // (canonically a zstd dictionary-compressed chunk). This is NEITHER corruption
2021 // NOR a checksum mismatch — the frame and its inline CRC are valid — so it must
2022 // NOT collapse into `ChunkDecompressionError` (truncation/bit-flip) or
2023 // `DigestMismatch`. Classify it FIRST, keyed on the authoritative error variant.
2024 //
2025 // INVARIANT (roborev): only a COMPRESSION-related `UnsupportedFormat` may reach
2026 // this scan classifier and earn the compression-specific class. Every such
2027 // producer names compression in its message — "Unknown/Unsupported compression
2028 // algorithm …", "<X> support not compiled in", or the zstd dictionary rejection
2029 // ("… dictionary compression … is unsupported …"). The version/format-detection
2030 // `UnsupportedFormat` producers fire at OPEN time and are classified on a
2031 // different path, so they never arrive here; but the coupling is implicit, so we
2032 // gate on the compression message-shape and fall through to the generic
2033 // `RowScanFailed` for any non-compression `UnsupportedFormat` rather than
2034 // mislabeling it as an unsupported compression feature. (Classifying an
2035 // already-typed error by message shape is not type inference; #28 is respected.)
2036 if matches!(err, Error::UnsupportedFormat(_))
2037 && (lower.contains("compress") || lower.contains("compiled in"))
2038 {
2039 return VerifyErrorClass::UnsupportedCompressionFeature;
2040 }
2041 if msg.contains("CRC32 mismatch") {
2042 VerifyErrorClass::ChunkDecompressionError
2043 } else if msg.contains("failed to fill whole buffer")
2044 || lower.contains("unexpected")
2045 || lower.contains("end of file")
2046 || msg.contains("too small")
2047 {
2048 VerifyErrorClass::UnexpectedEof
2049 } else if msg.contains("decompress")
2050 || msg.contains("Decompressed")
2051 || msg.contains("length prefix")
2052 {
2053 VerifyErrorClass::ChunkDecompressionError
2054 } else {
2055 VerifyErrorClass::RowScanFailed
2056 }
2057}
2058
2059#[cfg(test)]
2060mod tests {
2061 use super::*;
2062
2063 #[test]
2064 fn error_class_codes_are_stable() {
2065 assert_eq!(VerifyErrorClass::DigestMismatch.code(), "DigestMismatch");
2066 assert_eq!(
2067 VerifyErrorClass::ChunkOffsetOutOfBounds.code(),
2068 "ChunkOffsetOutOfBounds"
2069 );
2070 assert_eq!(
2071 VerifyErrorClass::BtiRootPointerCorrupt.code(),
2072 "BtiRootPointerCorrupt"
2073 );
2074 // issue #1282: the two new classes must expose stable codes.
2075 assert_eq!(
2076 VerifyErrorClass::OutOfOrderKeyOrRow.code(),
2077 "OutOfOrderKeyOrRow"
2078 );
2079 assert_eq!(
2080 VerifyErrorClass::InvalidLocalDeletionTime.code(),
2081 "InvalidLocalDeletionTime"
2082 );
2083 // issue #1414: the unsupported-compression-feature class must be stable.
2084 assert_eq!(
2085 VerifyErrorClass::UnsupportedCompressionFeature.code(),
2086 "UnsupportedCompressionFeature"
2087 );
2088 }
2089
2090 #[test]
2091 fn unsupported_compression_feature_classified_distinctly() {
2092 // issue #1414: a zstd dictionary rejection reaches the scan classifier as
2093 // `Error::UnsupportedFormat` and MUST map to the dedicated
2094 // `UnsupportedCompressionFeature` class — never the truncation/bit-flip
2095 // `ChunkDecompressionError` nor the checksum `DigestMismatch`.
2096 let dict_err = Error::UnsupportedFormat(
2097 "zstd dictionary compression (Dictionary_ID=1234) is unsupported for chunk 0 at offset 0x0"
2098 .to_string(),
2099 );
2100 assert_eq!(
2101 classify_scan_error_class(&dict_err),
2102 VerifyErrorClass::UnsupportedCompressionFeature
2103 );
2104 assert_ne!(
2105 classify_scan_error_class(&dict_err),
2106 VerifyErrorClass::ChunkDecompressionError
2107 );
2108 assert_ne!(
2109 classify_scan_error_class(&dict_err),
2110 VerifyErrorClass::DigestMismatch
2111 );
2112
2113 // Regression guard: a genuine plain-decode failure (truncation/bit-flip)
2114 // stays `ChunkDecompressionError` — the new class must not swallow it.
2115 let decode_err = Error::InvalidFormat(
2116 "Zstd decompression failed for chunk 0 at offset 0x0: corrupted input".to_string(),
2117 );
2118 assert_eq!(
2119 classify_scan_error_class(&decode_err),
2120 VerifyErrorClass::ChunkDecompressionError
2121 );
2122
2123 // A chunk inline-CRC mismatch also stays `ChunkDecompressionError`.
2124 let crc_err = Error::Corruption("Data.db chunk 0 CRC32 mismatch".to_string());
2125 assert_eq!(
2126 classify_scan_error_class(&crc_err),
2127 VerifyErrorClass::ChunkDecompressionError
2128 );
2129 }
2130
2131 #[test]
2132 fn non_compression_unsupported_format_falls_through_to_generic() {
2133 // roborev (issue #1414): the compression-specific class is reserved for
2134 // compression-related `UnsupportedFormat`. A NON-compression `UnsupportedFormat`
2135 // reaching this scan classifier (e.g. a hypothetical future decode-path feature
2136 // rejection) MUST NOT be mislabeled as an unsupported compression feature — it
2137 // falls through to the generic `RowScanFailed`.
2138 let non_compression = Error::UnsupportedFormat(
2139 "tuple element type not yet supported for chunk 0 at offset 0x0".to_string(),
2140 );
2141 assert_eq!(
2142 classify_scan_error_class(&non_compression),
2143 VerifyErrorClass::RowScanFailed
2144 );
2145 assert_ne!(
2146 classify_scan_error_class(&non_compression),
2147 VerifyErrorClass::UnsupportedCompressionFeature
2148 );
2149
2150 // Every real compression-related producer still earns the compression class,
2151 // regardless of the exact wording: the "not compiled in" build-config path…
2152 let not_compiled = Error::UnsupportedFormat("Zstd support not compiled in".to_string());
2153 assert_eq!(
2154 classify_scan_error_class(¬_compiled),
2155 VerifyErrorClass::UnsupportedCompressionFeature
2156 );
2157 // …and the unknown/unsupported algorithm path.
2158 let unknown_algo =
2159 Error::UnsupportedFormat("Unknown compression algorithm: BogusCompressor".to_string());
2160 assert_eq!(
2161 classify_scan_error_class(&unknown_algo),
2162 VerifyErrorClass::UnsupportedCompressionFeature
2163 );
2164 }
2165
2166 /// End-to-end wiring (issue #1414): a REAL trained-dictionary zstd frame,
2167 /// driven through the shipped `ChunkDecompressor`, must surface the typed
2168 /// `Error::UnsupportedFormat` that `classify_scan_error_class` maps to
2169 /// `UnsupportedCompressionFeature` — proving the reader error and the verify
2170 /// class agree end to end (not just on a hand-written message).
2171 #[cfg(feature = "zstd")]
2172 #[test]
2173 fn dictionary_frame_wires_reader_error_to_unsupported_class() {
2174 use crate::parser::header::CassandraVersion;
2175 use crate::storage::sstable::chunk_decompressor::ChunkDecompressor;
2176 use crate::storage::sstable::compression_info::CompressionInfo;
2177 use std::io::Cursor;
2178
2179 let plaintext =
2180 b"cqlite|zstd|dictionary|row=verify|table=zstd_dictionary_table|value=payload-7"
2181 .to_vec();
2182 let samples: Vec<Vec<u8>> = (0..1024u32)
2183 .map(|i| format!("cqlite|zstd|dictionary|row={i}|value={}", i % 37).into_bytes())
2184 .collect();
2185 let dict = zstd::dict::from_samples(&samples, 4 * 1024).expect("train zstd dictionary");
2186 let dict_frame = zstd::bulk::Compressor::with_dictionary(3, &dict)
2187 .expect("dictionary compressor")
2188 .compress(&plaintext)
2189 .expect("dictionary-compress chunk");
2190
2191 // Cassandra chunk framing: [compressed payload][4-byte BE CRC32].
2192 let mut image = dict_frame.clone();
2193 image.extend_from_slice(&crc32fast::hash(&dict_frame).to_be_bytes());
2194
2195 let info = CompressionInfo {
2196 algorithm: "ZstdCompressor".to_string(),
2197 option_pairs: vec![],
2198 chunk_length: plaintext.len() as u32,
2199 max_compressed_length: i32::MAX as u32,
2200 data_length: plaintext.len() as u64,
2201 chunk_offsets: vec![0],
2202 };
2203 let mut dec = ChunkDecompressor::new(info, CassandraVersion::V5_0Release)
2204 .expect("build decompressor");
2205 let err = dec
2206 .decompress_chunk_by_index(&mut Cursor::new(image), 0)
2207 .expect_err("dictionary frame must be rejected");
2208
2209 assert!(
2210 matches!(err, Error::UnsupportedFormat(_)),
2211 "reader must reject with UnsupportedFormat; got: {err}"
2212 );
2213 assert_eq!(
2214 classify_scan_error_class(&err),
2215 VerifyErrorClass::UnsupportedCompressionFeature,
2216 "verify must classify the reader's dictionary rejection as \
2217 UnsupportedCompressionFeature; got err: {err}"
2218 );
2219 }
2220
2221 #[test]
2222 fn mode_labels() {
2223 assert_eq!(VerifyMode::Quick.as_str(), "quick");
2224 assert_eq!(VerifyMode::Full.as_str(), "full");
2225 assert_ne!(VerifyMode::Quick, VerifyMode::Full);
2226 }
2227
2228 #[test]
2229 fn real_component_recognition_excludes_sidecars() {
2230 assert!(is_real_component("Data.db"));
2231 assert!(is_real_component("Statistics.db"));
2232 assert!(is_real_component("CompressionInfo.db"));
2233 assert!(is_real_component("TOC.txt"));
2234 assert!(is_real_component("Digest.crc32"));
2235 // sidecar / reference goldens are NOT components
2236 assert!(!is_real_component("Data.db.jsonl"));
2237 assert!(!is_real_component("Statistics.db.txt"));
2238 assert!(!is_real_component("CompressionInfo.db.txt"));
2239 assert!(!is_real_component("README.md"));
2240 }
2241
2242 #[test]
2243 fn report_summary_line_distinguishes_ok_and_fail() {
2244 let ok = VerifyReport {
2245 directory: PathBuf::from("/x"),
2246 base_name: "nb-1-big".to_string(),
2247 format: SsTableFormat::Big,
2248 mode: VerifyMode::Full,
2249 findings: vec![],
2250 toc_components: vec![],
2251 rows_scanned: Some(3),
2252 };
2253 assert!(ok.is_ok());
2254 assert!(ok.summary_line().contains("VERIFY OK"));
2255
2256 let fail = VerifyReport {
2257 directory: PathBuf::from("/x"),
2258 base_name: "nb-1-big".to_string(),
2259 format: SsTableFormat::Big,
2260 mode: VerifyMode::Full,
2261 findings: vec![VerifyFinding::new(
2262 VerifyErrorClass::DigestMismatch,
2263 "Digest.crc32",
2264 "boom",
2265 )],
2266 toc_components: vec![],
2267 rows_scanned: None,
2268 };
2269 assert!(!fail.is_ok());
2270 assert_eq!(fail.primary_class(), Some(VerifyErrorClass::DigestMismatch));
2271 assert!(fail.summary_line().contains("VERIFY FAIL"));
2272 assert!(fail.summary_line().contains("DigestMismatch"));
2273 }
2274
2275 // ---- BTI partition identity cross-check (issue #1103) ------------------
2276 //
2277 // These exercise `bti_partition_identity_mismatch` over RESOLVED leaves: each
2278 // leaf carries its emitted byte-comparable prefix plus a payload resolved back
2279 // to a raw partition key (an inline raw key for a `RowsOffset` leaf, or a
2280 // Data.db position for a `DataOffset` leaf). The Data.db side is the
2281 // `(position, raw_key)` set from the scan.
2282
2283 use crate::storage::sstable::bti::parser::encode_partition_key_for_bti_trie;
2284
2285 /// Build the path-compressed trie key for a raw partition key: the
2286 /// byte-comparable `[0x40 ++ token]` key truncated to its first `prefix_len`
2287 /// bytes, mirroring how a real Patricia trie stores only the shortest
2288 /// distinguishing prefix.
2289 fn trie_key_prefix(raw: &[u8], prefix_len: usize) -> Vec<u8> {
2290 encode_partition_key_for_bti_trie(raw)[..prefix_len].to_vec()
2291 }
2292
2293 /// A `RowsOffset`-style leaf: authoritative inline raw key + matching Data.db
2294 /// position, with a 2-byte emitted prefix (what `test_da/wide_table` does).
2295 fn inline_leaf(raw: &[u8], data_position: u64) -> BtiResolvedLeaf {
2296 BtiResolvedLeaf {
2297 prefix: trie_key_prefix(raw, 2),
2298 inline_raw_key: Some(raw.to_vec()),
2299 data_position,
2300 }
2301 }
2302
2303 /// A `DataOffset`-style leaf: no inline key, resolved purely via its Data.db
2304 /// position, with a 2-byte emitted prefix derived from the key it *should*
2305 /// resolve to (so the prefix/payload-consistency check passes when healthy).
2306 fn data_offset_leaf(prefix_from: &[u8], data_position: u64) -> BtiResolvedLeaf {
2307 BtiResolvedLeaf {
2308 prefix: trie_key_prefix(prefix_from, 2),
2309 inline_raw_key: None,
2310 data_position,
2311 }
2312 }
2313
2314 /// The Data.db scan side: distinct partition keys, each at a synthetic
2315 /// monotonically-increasing position (0, 100, 200, ...).
2316 fn data_partitions(keys: &[Vec<u8>]) -> Vec<(u64, Vec<u8>)> {
2317 keys.iter()
2318 .enumerate()
2319 .map(|(i, k)| (i as u64 * 100, k.clone()))
2320 .collect()
2321 }
2322
2323 #[test]
2324 fn identity_check_passes_for_inline_rows_leaves() {
2325 // Healthy wide-table shape: every leaf resolves to its inline raw key,
2326 // matching the Data.db key at the same position.
2327 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2328 let data = data_partitions(&keys);
2329 let leaves: Vec<BtiResolvedLeaf> =
2330 data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
2331 assert_eq!(bti_partition_identity_mismatch(&leaves, &data), None);
2332 }
2333
2334 #[test]
2335 fn identity_check_passes_for_data_offset_leaves() {
2336 // Healthy small-partition shape (`da-2-bti`): leaves carry only a Data.db
2337 // position; the raw key is resolved through the position map.
2338 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2339 let data = data_partitions(&keys);
2340 let leaves: Vec<BtiResolvedLeaf> = data
2341 .iter()
2342 .map(|(pos, k)| data_offset_leaf(k, *pos))
2343 .collect();
2344 assert_eq!(bti_partition_identity_mismatch(&leaves, &data), None);
2345 }
2346
2347 #[test]
2348 fn identity_check_detects_inline_payload_pointing_at_wrong_partition() {
2349 // The exact reviewer scenario for a `RowsOffset` leaf: the leaf's emitted
2350 // prefix is unchanged but its INLINE raw key (the payload) is rewritten to
2351 // a partition NOT present in Data.db. Same leaf count, wrong identity.
2352 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2353 let data = data_partitions(&keys);
2354 let mut leaves: Vec<BtiResolvedLeaf> =
2355 data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
2356 // Keep the emitted prefix; rewrite the inline raw key to pk=99.
2357 leaves[0].inline_raw_key = Some(99u32.to_be_bytes().to_vec());
2358 assert!(
2359 bti_partition_identity_mismatch(&leaves, &data).is_some(),
2360 "an inline payload pointing at a partition absent from Data.db must be flagged"
2361 );
2362 }
2363
2364 #[test]
2365 fn identity_check_detects_data_offset_payload_pointing_at_wrong_partition() {
2366 // The reviewer scenario for a `DataOffset` leaf: the leaf's emitted prefix
2367 // is unchanged but its Data.db position payload is rewritten to point at a
2368 // DIFFERENT partition's start. The resolved key then no longer matches the
2369 // partition the prefix encodes.
2370 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2371 let data = data_partitions(&keys);
2372 let mut leaves: Vec<BtiResolvedLeaf> = data
2373 .iter()
2374 .map(|(pos, k)| data_offset_leaf(k, *pos))
2375 .collect();
2376 // Leaf 0's prefix still encodes pk=1, but its position now points at pk=2.
2377 leaves[0].data_position = data[1].0;
2378 let detail = bti_partition_identity_mismatch(&leaves, &data)
2379 .expect("a DataOffset payload pointing at the wrong partition must be flagged");
2380 // It is caught by the prefix/payload-consistency check (the resolved key's
2381 // encoding no longer starts with the leaf's prefix) OR the multiset compare.
2382 assert!(
2383 detail.contains("inconsistent") || detail.contains("identities"),
2384 "unexpected detail: {detail}"
2385 );
2386 }
2387
2388 #[test]
2389 fn identity_check_detects_data_offset_payload_pointing_at_non_partition() {
2390 // A `DataOffset` flipped to a byte position that is NOT a partition start
2391 // resolves to no key at all.
2392 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2393 let data = data_partitions(&keys);
2394 let mut leaves: Vec<BtiResolvedLeaf> = data
2395 .iter()
2396 .map(|(pos, k)| data_offset_leaf(k, *pos))
2397 .collect();
2398 leaves[0].data_position = 37; // not any partition start
2399 let detail = bti_partition_identity_mismatch(&leaves, &data)
2400 .expect("a DataOffset pointing at a non-partition position must be flagged");
2401 assert!(detail.contains("not a decoded partition start"));
2402 }
2403
2404 #[test]
2405 fn identity_check_detects_same_count_wrong_keys_via_multiset() {
2406 // Same leaf count as Data.db and every leaf is individually well-formed
2407 // (valid key, valid in-map position, consistent prefix) — but the trie
2408 // resolves the SAME partition three times instead of {1,2,3}. Only the
2409 // multiset comparison catches this; it is the core of issue #1103.
2410 let data_keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2411 let data = data_partitions(&data_keys);
2412 // Three leaves all resolving to partition 1 (key + position from data[0]).
2413 let leaves: Vec<BtiResolvedLeaf> =
2414 (0..3).map(|_| inline_leaf(&data[0].1, data[0].0)).collect();
2415 let detail = bti_partition_identity_mismatch(&leaves, &data)
2416 .expect("same-count wrong-identity must be flagged");
2417 assert!(
2418 detail.contains("identities") || detail.contains("time(s)"),
2419 "expected a multiset-identity mismatch, got: {detail}"
2420 );
2421 }
2422
2423 #[test]
2424 fn identity_check_detects_inline_leaf_with_corrupt_data_position() {
2425 // Reviewer (roborev #1431): a `RowsOffset` leaf whose INLINE key is valid
2426 // and present in Data.db but whose recorded Data.db position points at a
2427 // non-partition offset must be flagged — a BTI read would seek to the wrong
2428 // partition even though the inline key looks fine.
2429 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2430 let data = data_partitions(&keys);
2431 let mut leaves: Vec<BtiResolvedLeaf> =
2432 data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
2433 // Keep the valid inline key; corrupt only the recorded Data.db position.
2434 leaves[0].data_position = 9999; // not any partition start
2435 let detail = bti_partition_identity_mismatch(&leaves, &data).expect(
2436 "an inline leaf with a valid key but a non-partition data position must be flagged",
2437 );
2438 assert!(detail.contains("not a decoded partition start"));
2439 }
2440
2441 #[test]
2442 fn identity_check_detects_one_swapped_key() {
2443 // Two keys match, one is wrong — the minimal wrong-root that a count check
2444 // cannot see.
2445 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2446 let data = data_partitions(&keys);
2447 let mut leaves: Vec<BtiResolvedLeaf> =
2448 data.iter().map(|(pos, k)| inline_leaf(k, *pos)).collect();
2449 // Replace leaf 0 with a key (pk=99) absent from Data.db, including its
2450 // prefix, and a position that is not a partition start.
2451 leaves[0] = inline_leaf(&99u32.to_be_bytes(), 10_000);
2452 assert!(bti_partition_identity_mismatch(&leaves, &data).is_some());
2453 }
2454
2455 // ---- Check 8: key/row order + partition-level LDT (issue #1282) --------
2456
2457 use crate::util::cassandra_murmur3::cassandra_murmur3_token;
2458
2459 /// Build the on-disk-ordered partition list the classifier consumes, sorting
2460 /// the supplied keys by their real Murmur3 `(token, key)` order so the "in
2461 /// order" input mirrors what a healthy Cassandra SSTable produces.
2462 fn ordered_partitions(keys: &[Vec<u8>]) -> Vec<(Vec<u8>, Option<i32>)> {
2463 let mut v: Vec<Vec<u8>> = keys.to_vec();
2464 v.sort_by_key(|k| (cassandra_murmur3_token(k), k.clone()));
2465 v.into_iter().map(|k| (k, None)).collect()
2466 }
2467
2468 #[test]
2469 fn order_ldt_clean_partitions_produce_no_findings() {
2470 let keys: Vec<Vec<u8>> = (1u32..=6).map(|i| i.to_be_bytes().to_vec()).collect();
2471 let partitions = ordered_partitions(&keys);
2472 assert!(
2473 classify_order_and_ldt(&partitions, true).is_empty(),
2474 "in-token-order partitions with live LDT must produce zero findings"
2475 );
2476 }
2477
2478 #[test]
2479 fn order_ldt_detects_out_of_order_partition_keys() {
2480 // Take the correctly-ordered set and swap the first two, forcing a
2481 // descending (token, key) step Cassandra's verifier rejects.
2482 let keys: Vec<Vec<u8>> = (1u32..=6).map(|i| i.to_be_bytes().to_vec()).collect();
2483 let mut partitions = ordered_partitions(&keys);
2484 partitions.swap(0, 1);
2485 let findings = classify_order_and_ldt(&partitions, true);
2486 assert!(
2487 findings
2488 .iter()
2489 .any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
2490 "swapping two partitions must be flagged OutOfOrderKeyOrRow, got {:?}",
2491 findings
2492 );
2493 }
2494
2495 #[test]
2496 fn order_ldt_detects_duplicate_partition_token_as_out_of_order() {
2497 // Equal (token, key) is NOT strictly greater → out of order.
2498 let k = 7u32.to_be_bytes().to_vec();
2499 let partitions = vec![(k.clone(), None), (k, None)];
2500 let findings = classify_order_and_ldt(&partitions, true);
2501 assert!(findings
2502 .iter()
2503 .any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow));
2504 }
2505
2506 #[test]
2507 fn order_ldt_flags_negative_ldt_on_signed_nb_form() {
2508 // A deleted partition (Some(ldt)) with a negative ldt on the SIGNED (nb)
2509 // form is corrupt — Cassandra's DeletionTime/Verifier rejects it.
2510 let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
2511 partitions[0].1 = Some(-1);
2512 let findings = classify_order_and_ldt(&partitions, /*signed_ldt=*/ true);
2513 assert!(
2514 findings
2515 .iter()
2516 .any(|f| f.class == VerifyErrorClass::InvalidLocalDeletionTime),
2517 "negative nb localDeletionTime must be flagged, got {:?}",
2518 findings
2519 );
2520 }
2521
2522 #[test]
2523 fn order_ldt_does_not_flag_far_future_ldt_on_unsigned_oa_form() {
2524 // On the UNSIGNED (oa/da) form a value in [2^31, 2^32) is a legitimate
2525 // far-future deletion time carried as a negative i32 — it MUST NOT be
2526 // flagged. This is the no-heuristic guard: the format, not the sign, decides.
2527 let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
2528 partitions[0].1 = Some(-1); // == 0xFFFFFFFF unsigned == far-future seconds
2529 let findings = classify_order_and_ldt(&partitions, /*signed_ldt=*/ false);
2530 assert!(
2531 !findings
2532 .iter()
2533 .any(|f| f.class == VerifyErrorClass::InvalidLocalDeletionTime),
2534 "far-future unsigned oa/da LDT must NOT be flagged, got {:?}",
2535 findings
2536 );
2537 }
2538
2539 #[test]
2540 fn order_ldt_positive_deletion_time_is_clean() {
2541 // A normal positive epoch-seconds partition tombstone is valid on both forms.
2542 let mut partitions = ordered_partitions(&[1u32.to_be_bytes().to_vec()]);
2543 partitions[0].1 = Some(1_700_000_000); // ~2023, valid
2544 assert!(classify_order_and_ldt(&partitions, true).is_empty());
2545 assert!(classify_order_and_ldt(&partitions, false).is_empty());
2546 }
2547
2548 // ---- Check 8 ROW half: clustering-row order (issue #1282 follow-up) -----
2549
2550 use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
2551 use crate::types::Value;
2552 use std::collections::HashMap;
2553
2554 fn schema_one_ck(order: ClusteringOrder) -> TableSchema {
2555 TableSchema {
2556 keyspace: "issue_1282".to_string(),
2557 table: "tbl".to_string(),
2558 partition_keys: vec![KeyColumn {
2559 name: "pk".to_string(),
2560 data_type: "int".to_string(),
2561 position: 0,
2562 }],
2563 clustering_keys: vec![ClusteringColumn {
2564 name: "ck".to_string(),
2565 data_type: "int".to_string(),
2566 position: 0,
2567 order,
2568 }],
2569 columns: vec![Column {
2570 name: "v".to_string(),
2571 data_type: "text".to_string(),
2572 nullable: true,
2573 default: None,
2574 is_static: false,
2575 }],
2576 comments: HashMap::new(),
2577 dropped_columns: HashMap::new(),
2578 }
2579 }
2580
2581 fn ck_int(n: i32) -> Vec<Value> {
2582 vec![Value::Integer(n)]
2583 }
2584
2585 #[test]
2586 fn clustering_order_ascending_rows_are_clean() {
2587 let schema = schema_one_ck(ClusteringOrder::Asc);
2588 let partitions = vec![(0usize, vec![ck_int(1), ck_int(2), ck_int(3)])];
2589 assert!(
2590 classify_clustering_row_order(&partitions, &schema).is_empty(),
2591 "in-order ASC clustering rows must produce no findings"
2592 );
2593 }
2594
2595 #[test]
2596 fn clustering_order_out_of_order_row_is_flagged() {
2597 // Row 3 comes before row 2 on disk under ASC — corrupt.
2598 let schema = schema_one_ck(ClusteringOrder::Asc);
2599 let partitions = vec![(0usize, vec![ck_int(1), ck_int(3), ck_int(2)])];
2600 let findings = classify_clustering_row_order(&partitions, &schema);
2601 assert!(
2602 findings
2603 .iter()
2604 .any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
2605 "an out-of-order clustering row must be flagged OutOfOrderKeyOrRow, got {:?}",
2606 findings
2607 );
2608 }
2609
2610 #[test]
2611 fn clustering_order_duplicate_row_is_flagged() {
2612 // Equal consecutive clustering keys are NOT strictly increasing → corrupt.
2613 let schema = schema_one_ck(ClusteringOrder::Asc);
2614 let partitions = vec![(0usize, vec![ck_int(5), ck_int(5)])];
2615 let findings = classify_clustering_row_order(&partitions, &schema);
2616 assert!(findings
2617 .iter()
2618 .any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow));
2619 }
2620
2621 #[test]
2622 fn clustering_order_respects_desc_ordering() {
2623 let schema = schema_one_ck(ClusteringOrder::Desc);
2624 // DESC on disk stores clustering values descending; 3,2,1 is IN ORDER.
2625 let ok = vec![(0usize, vec![ck_int(3), ck_int(2), ck_int(1)])];
2626 assert!(
2627 classify_clustering_row_order(&ok, &schema).is_empty(),
2628 "descending rows under DESC clustering order must be clean"
2629 );
2630 // Ascending 1,2,3 is OUT OF ORDER under DESC.
2631 let bad = vec![(0usize, vec![ck_int(1), ck_int(2), ck_int(3)])];
2632 assert!(
2633 classify_clustering_row_order(&bad, &schema)
2634 .iter()
2635 .any(|f| f.class == VerifyErrorClass::OutOfOrderKeyOrRow),
2636 "ascending rows under a DESC clustering column must be flagged"
2637 );
2638 }
2639
2640 #[test]
2641 fn identity_check_detects_count_mismatch() {
2642 let keys: Vec<Vec<u8>> = (1u32..=3).map(|i| i.to_be_bytes().to_vec()).collect();
2643 let data = data_partitions(&keys);
2644 // Only two leaves recovered from the trie (undercount).
2645 let leaves: Vec<BtiResolvedLeaf> = data
2646 .iter()
2647 .take(2)
2648 .map(|(pos, k)| inline_leaf(k, *pos))
2649 .collect();
2650 let detail =
2651 bti_partition_identity_mismatch(&leaves, &data).expect("undercount must be flagged");
2652 assert!(detail.contains("2 partition keys"));
2653 assert!(detail.contains("3 distinct partitions"));
2654 }
2655
2656 // ---- Finding 1 (roborev round 2): tolerate reader filename shapes -------
2657 //
2658 // `SSTableReader::open` does not enforce a "-Data.db" suffix and still opens a
2659 // file whose name it cannot map (it just skips siblings). A reader that opened
2660 // MUST get an `IntegrityCheckResult` from `perform_integrity_check`, not an
2661 // `Err`, so `build_component_set` (the resolution the integrity check ultimately
2662 // drives) must never reject on the suffix.
2663
2664 #[test]
2665 fn build_component_set_matches_reader_base_name_for_non_data_db_name() {
2666 // A name that does NOT end in "-Data.db" but that the reader's own
2667 // base-name derivation accepts must resolve to the SAME base name the
2668 // reader uses for sibling lookup — never an Err (issue #1283, roborev).
2669 let p = PathBuf::from("/dir/nb-7-big-Statistics.db");
2670 let set = build_component_set(&[p.clone()], p.clone())
2671 .expect("reader-accepted non-Data.db name must not error");
2672 assert_eq!(
2673 Some(set.base_name),
2674 extract_sstable_base_name(&p),
2675 "verify base name must match SSTableReader::open's base-name derivation"
2676 );
2677 }
2678
2679 #[test]
2680 fn build_component_set_degrades_on_unmappable_name() {
2681 // A name the reader can open but that neither ends in "-Data.db" nor maps
2682 // via the reader's derivation degrades to the filename minus ".db" (verify
2683 // what we can) rather than erroring.
2684 let p = PathBuf::from("/dir/weird.db");
2685 let set = build_component_set(&[], p.clone()).expect("must degrade, not error");
2686 assert_eq!(set.base_name, "weird");
2687 assert_eq!(set.data_path, p);
2688 }
2689
2690 #[test]
2691 fn build_component_set_standard_name_still_resolves_canonically() {
2692 let p = PathBuf::from("/dir/nb-3-big-Data.db");
2693 let set = build_component_set(&[p.clone()], p).expect("standard name resolves");
2694 assert_eq!(set.base_name, "nb-3-big");
2695 }
2696
2697 // ---- Finding 2 (roborev round 2): relative Data.db path, empty parent ---
2698 //
2699 // A relative, directory-less filename yields an EMPTY parent from
2700 // `Path::parent()` (Some(""), not None). `generation_dir` must normalize that
2701 // to "." so sibling components are scanned in the current directory — matching
2702 // where `SSTableReader::open` found the file.
2703
2704 #[test]
2705 fn generation_dir_normalizes_empty_parent_to_current_dir() {
2706 // Relative bare filename opened from the SSTable dir as cwd: empty parent → ".".
2707 assert_eq!(
2708 generation_dir(Path::new("nb-1-big-Data.db")),
2709 Path::new("."),
2710 "a relative directory-less Data.db must resolve against the current directory"
2711 );
2712 // Absolute path keeps its real parent.
2713 assert_eq!(
2714 generation_dir(Path::new("/x/y/nb-1-big-Data.db")),
2715 Path::new("/x/y")
2716 );
2717 // Relative path WITH a directory component keeps that directory.
2718 assert_eq!(
2719 generation_dir(Path::new("sub/nb-1-big-Data.db")),
2720 Path::new("sub")
2721 );
2722 }
2723}