Skip to main content

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(&not_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}