Skip to main content

cqlite_core/parser/
repair_metadata.rs

1//! Repair-state metadata decode from the `Statistics.db` STATS component
2//! (issue #988, epic #968).
3//!
4//! Apache Cassandra persists three repair-coordination fields inside the STATS
5//! `MetadataType` component of `Statistics.db`:
6//!
7//!   * `repairedAt` (`long`) — the repair timestamp, `0` for an unrepaired
8//!     SSTable. Rendered by `sstablemetadata` as `Repaired at: <n>`.
9//!   * `pendingRepair` (`UUID`, nullable) — the in-flight (incremental) repair
10//!     session id, `null` for an SSTable not part of a pending repair. Rendered
11//!     as `Pending repair: --` when null.
12//!   * `isTransient` (`boolean`) — whether the SSTable holds only transiently
13//!     replicated data. Rendered as `IsTransient: false`.
14//!
15//! # Scope — persisted-metadata parse/report ONLY
16//!
17//! This module decodes and **reports** the *persisted* repair state. It does
18//! **not** implement, and must not be read as implying, repair coordination,
19//! incremental-repair session tracking, or **repair-aware compaction /
20//! tombstone purging**. Exposing `repairedAt` here establishes nothing about
21//! whether tombstones are safe to purge against a repair boundary — that is a
22//! separate correctness concern that lives in the compaction layer.
23//!
24//! # What is decoded from real bytes
25//!
26//! `repairedAt` is decoded directly from the STATS component. It is reachable by
27//! a fully *self-describing* forward walk over the leading STATS fields (two
28//! `EstimatedHistogram`s and one `TombstoneHistogram`, every one length-prefixed
29//! and free of any column-type dependency), so it is decoded for **every**
30//! storage format (nb / oa / da) without needing the serialization header.
31//!
32//! `pendingRepair` and `isTransient` sit *after* the version-gated
33//! `improvedMinMax` / `legacyMinMax` block and the variable `commitLogIntervals`
34//! set, both of which require version-gated / type-aware skipping to traverse.
35//!
36//! # Full repair-state walk (issue #1021)
37//!
38//! When the caller supplies authoritative [`VersionGates`],
39//! [`parse_repair_metadata`] now performs that version-gated forward walk and
40//! decodes `pendingRepair` (UUID) and `isTransient` **from real bytes** in
41//! addition to `repairedAt`. The walk is driven ENTIRELY by the version gates
42//! (`hasLegacyMinMax` / `hasImprovedMinMax` / `hasUIntDeletionTime` /
43//! `hasPendingRepair` / `hasIsTransient`) read from the parsed descriptor — no
44//! heuristics, no type guessing (#28). Mirrors the exact field order of
45//! cassandra-5.0.0 `StatsMetadata.StatsMetadataSerializer.serialize`, the same
46//! layout the `da` STATS writer emits (`build_stats_component_da`).
47//!
48//! When `gates` is `None` the walk past `repairedAt` is NOT attempted (the
49//! min/max block and the commit-log-interval shape are version-dependent and
50//! cannot be traversed without the descriptor), so `pendingRepair` /
51//! `isTransient` are reported honestly as [`RepairField::Unparsed`] rather than a
52//! fabricated `null` / `false` — a real SSTable that carried a pending-repair
53//! UUID or transient flag is never silently misreported as absent.
54
55use crate::error::{Error, Result};
56use crate::parser::repair_clustering::{
57    resolve_clustering_value_layout, skip_covered_slice, ByteSkip,
58};
59use crate::storage::sstable::version_gate::VersionGates;
60
61/// Cassandra `MetadataType.STATS` ordinal (MetadataType.java).
62const METADATA_TYPE_STATS: u32 = 2;
63/// Cassandra `MetadataType.HEADER` ordinal (SerializationHeader; MetadataType.java).
64const METADATA_TYPE_HEADER: u32 = 3;
65
66/// A STATS repair field that may either have been decoded from real bytes
67/// (`Decoded`) or is honestly reported as not-yet-decoded (`Unparsed`).
68///
69/// This exists so the public API never fabricates a concrete value for a field
70/// this module does not actually walk from the STATS bytes. A real SSTable
71/// carrying a pending-repair UUID or a transient flag is reported as `Unparsed`
72/// — never silently misreported as the absent/false default — until a
73/// type-aware forward walk past `improvedMinMax` + `commitLogIntervals` is
74/// implemented (which requires a fixture that does not exist in the corpus; see
75/// module docs).
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RepairField<T> {
78    /// The field was decoded directly from the STATS-component bytes.
79    Decoded(T),
80    /// The field was not decoded; its real value is unknown. Callers must NOT
81    /// treat this as the absent/default state.
82    Unparsed,
83}
84
85impl<T> RepairField<T> {
86    /// The decoded value, or `None` when the field is `Unparsed`. Callers that
87    /// need to distinguish "decoded as absent" from "not decoded" must match on
88    /// the variant directly rather than using this.
89    pub fn decoded(&self) -> Option<&T> {
90        match self {
91            RepairField::Decoded(v) => Some(v),
92            RepairField::Unparsed => None,
93        }
94    }
95
96    /// Whether this field was decoded from real bytes.
97    pub fn is_decoded(&self) -> bool {
98        matches!(self, RepairField::Decoded(_))
99    }
100}
101
102/// Repair-coordination metadata persisted in the `Statistics.db` STATS
103/// component. Read-side / report-only (see module docs).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RepairMetadata {
106    /// `repairedAt` timestamp; `0` for an unrepaired SSTable. Decoded directly
107    /// from the STATS-component bytes (genuinely walked, so concrete).
108    pub repaired_at: i64,
109
110    /// `pendingRepair` session UUID (`Decoded(Some(uuid))`), an explicitly
111    /// decoded null (`Decoded(None)`), or `Unparsed` when this module did not
112    /// decode it from the STATS bytes.
113    ///
114    /// Decoded from real bytes by the version-gated forward walk when
115    /// authoritative [`VersionGates`] are supplied. `hasPendingRepair` is
116    /// `>= na`, so the field is present and decoded for every Cassandra 5.0
117    /// format CQLite reads (`nb`/`oa`/`da`). `Unparsed` is reserved for "present
118    /// but not safely reachable" (an unmodeled clustering comparator) or when
119    /// `gates` is `None` (the walk past `repairedAt` is not attempted). See
120    /// module docs.
121    pub pending_repair: RepairField<Option<[u8; 16]>>,
122
123    /// `isTransient` flag (`Decoded(bool)`) or `Unparsed` when not decoded.
124    ///
125    /// Decoded from real bytes by the version-gated forward walk when
126    /// authoritative [`VersionGates`] are supplied. `hasIsTransient` is `>= na`,
127    /// so the field is present and decoded for every supported Cassandra 5.0
128    /// format (`nb`/`oa`/`da`). `Unparsed` is reserved for "present but not
129    /// safely reachable" or when `gates` is `None`.
130    pub is_transient: RepairField<bool>,
131
132    /// `true` when `repaired_at` was decoded from the STATS component bytes;
133    /// `false` when it could only be reported as the unrepaired default (e.g.
134    /// the STATS component was not locatable). Lets callers distinguish a
135    /// byte-proven value from a defaulted one.
136    pub repaired_at_decoded: bool,
137}
138
139impl RepairMetadata {
140    /// The canonical unrepaired state for an SSTable whose STATS component could
141    /// not be located: `repairedAt` defaults to `0` (undecoded), and the two
142    /// not-walked fields are reported honestly as `Unparsed`.
143    pub fn unrepaired_default() -> Self {
144        RepairMetadata {
145            repaired_at: 0,
146            pending_repair: RepairField::Unparsed,
147            is_transient: RepairField::Unparsed,
148            repaired_at_decoded: false,
149        }
150    }
151}
152
153/// Number of CRC bytes Cassandra writes after EACH metadata component. The
154/// `Statistics.db` MetadataSerializer emits a 4-byte CRC32 (over the component's
155/// own bytes) immediately after every component body — including the last.
156/// Verified empirically against real nb/oa/da fixtures: for a component at TOC
157/// offset `o` whose successor is at offset `next`, the bytes `[next-4, next)`
158/// are `crc32(buf[o..next-4])`; for the final component the CRC occupies the
159/// last 4 bytes of the file. The STATS component must never be decoded into this
160/// CRC, so both the next-component bound and the last-component bound subtract it.
161const METADATA_COMPONENT_CRC_LEN: usize = 4;
162
163/// The located STATS component, as a `[start, end)` byte range within the
164/// `Statistics.db` buffer. `end` is the *next* component's offset minus the
165/// 4-byte per-component CRC that sits between STATS and that component (the
166/// smallest component offset strictly greater than `start`), or — when STATS is
167/// the last component — the end of the file minus the trailing 4-byte CRC. The
168/// decode cursor is constructed over ONLY this slice so that a truncated STATS
169/// body or an over-long internal length field fails closed instead of spilling
170/// into the CRC or the following component.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172struct StatsComponentBounds {
173    start: usize,
174    end: usize,
175}
176
177/// Locate the byte range of the STATS (`MetadataType` ordinal 2) component from
178/// the `Statistics.db` Table of Contents.
179///
180/// The TOC carries `(type, offset)` pairs for every metadata component. The
181/// STATS component's end is derived authoritatively from those same offsets —
182/// the next component begins where STATS must end — rather than from the rest of
183/// the file. When STATS is the final component, its end is the start of the
184/// trailing CRC.
185///
186/// Every Cassandra 5.0 format CQLite reads (`nb` BIG, `oa` BIG, `da` BTI) is
187/// `>= na`, so `hasMetadataChecksum` is always true and
188/// `MetadataSerializer.serialize` always writes a 4-byte CRC32 after each
189/// component body (`MetadataSerializer.java:110-111,117`). The body bound
190/// therefore always subtracts that CRC. (Pre-`na` BIG versions — `ma`–`me` — are
191/// out of scope: CQLite is a Cassandra 5.0 reader and does not open them.)
192///
193/// Returns:
194///   * `Ok(None)` ONLY when the TOC is well-formed but carries NO STATS
195///     component (nothing to decode → the caller reports the unrepaired
196///     default).
197///   * `Ok(Some(bounds))` for a valid STATS range.
198///   * `Err(Corruption)` for a malformed/truncated TOC (too short for the
199///     header, component count out of range, or a TOC that overruns the
200///     buffer), OR when a STATS entry IS present but its derived range is
201///     invalid (offset past EOF, inverted, or zero-length) — fail closed so a
202///     corrupt `Statistics.db` is never silently reported as unrepaired.
203fn stats_component_bounds(input: &[u8]) -> Result<Option<StatsComponentBounds>> {
204    parse_statistics_toc(input).stats_bounds()
205}
206
207/// The `Statistics.db` Table-of-Contents parsed ONCE (issue #2148): the resolved
208/// HEADER (SerializationHeader) offset and the STATS component `[start, end)`
209/// bounds, both extracted in a single forward walk.
210///
211/// Before #2148 the metadata stack re-walked the same on-disk TOC **three times**
212/// per open — once for the HEADER offset in
213/// [`crate::parser::enhanced_statistics_parser`], then once each inside
214/// [`read_table_counts`] and [`parse_stats_extras`]. Parsing the TOC once and
215/// threading this struct to those consumers (`*_with_toc`) eliminates the two
216/// redundant walks (`parser::toc_walk_metrics` drops from 3 → 1 per parse).
217/// Crate-private (issue #2148 blocker): this reuse type has no external consumers
218/// (only the in-crate `enhanced_statistics_parser`), and its `*_with_toc` consumers
219/// slice `input` by bounds carried in the TOC. Keeping it `pub(crate)` guarantees a
220/// `StatisticsToc` parsed from one buffer can never be paired with a different
221/// (smaller) buffer at a public boundary, which would index out of range.
222#[derive(Debug, Clone)]
223pub(crate) struct StatisticsToc {
224    /// Offset of the HEADER (SerializationHeader) component, or `None` when the
225    /// TOC has no HEADER entry OR is too malformed to locate one. Resolved
226    /// INDEPENDENTLY of the STATS bounds (issue #2148): a HEADER entry found before
227    /// an invalid STATS byte-range is still returned here, so EncodingStats decodes
228    /// authoritatively from it even while [`Self::stats_bounds`] fails closed. This
229    /// preserves the pre-#2148 `parse_statistics_toc_for_header_offset` semantics,
230    /// whose header walk never depended on any STATS-range check.
231    header_offset: Option<usize>,
232    /// STATS component `[start, end)` bounds. `Ok(None)` = a well-formed TOC that
233    /// carries no STATS component; `Err(msg)` = a malformed/truncated TOC or an
234    /// invalid STATS range (fail-closed). The corruption *message* is stored (not
235    /// `Error`, which is not `Clone`) so BOTH the row-count and stats-extras
236    /// consumers can each rebuild the same `Error::Corruption` from one shared
237    /// parse.
238    stats_bounds: std::result::Result<Option<StatsComponentBounds>, String>,
239}
240
241impl StatisticsToc {
242    /// The resolved HEADER (SerializationHeader) component offset (lenient `None`).
243    pub(crate) fn header_offset(&self) -> Option<usize> {
244        self.header_offset
245    }
246
247    /// The STATS component bounds as a strict [`Result`], reconstructing the
248    /// fail-closed `Error::Corruption` for a malformed TOC / invalid range so
249    /// consumers see identical error semantics to the pre-#2148 per-consumer walk.
250    fn stats_bounds(&self) -> Result<Option<StatsComponentBounds>> {
251        match &self.stats_bounds {
252            Ok(v) => Ok(*v),
253            Err(msg) => Err(Error::Corruption(msg.clone())),
254        }
255    }
256}
257
258/// Parse the `Statistics.db` TOC ONCE (issue #2148), resolving both the HEADER
259/// offset and the STATS component bounds in a single pass.
260///
261/// This is the single TOC-walk site the `parser::toc_walk_metrics` counter
262/// records — consumers that accept a [`StatisticsToc`] (`*_with_toc`) add no
263/// further walk. A malformed/truncated TOC yields `header_offset = None` (lenient)
264/// and a fail-closed STATS-bounds error (surfaced when a consumer reads it).
265pub(crate) fn parse_statistics_toc(input: &[u8]) -> StatisticsToc {
266    // Count this TOC walk (issue #1658 A5 bench instrumentation — no decode effect).
267    crate::parser::toc_walk_metrics::record_toc_walk();
268    // The walk resolves the HEADER offset and the STATS bounds INDEPENDENTLY: a
269    // corrupt STATS byte-range must never nuke a HEADER that was already located
270    // (issue #2148 roborev/reviewer blocker) — a valid HEADER still decodes
271    // EncodingStats authoritatively even when the STATS range fails closed.
272    let (header_offset, stats_bounds) = walk_statistics_toc(input);
273    StatisticsToc {
274        header_offset,
275        stats_bounds,
276    }
277}
278
279/// Single forward walk over the `Statistics.db` TOC resolving the HEADER offset
280/// and the STATS `[start, end)` bounds together. Pure (no counter side effect);
281/// the public [`parse_statistics_toc`] records the walk and wraps the outcome.
282///
283/// The two outputs are resolved INDEPENDENTLY, exactly as the pre-#2148 separate
284/// walks (`parse_statistics_toc_for_header_offset` for the header, and
285/// `stats_component_bounds` for STATS) were: the returned `header_offset` is the
286/// first present HEADER entry (lenient — `None` when the TOC is malformed/truncated
287/// or carries no HEADER). The STATS-bounds `Result` FAILS CLOSED (`Err(msg)`) on a
288/// malformed/truncated TOC or an invalid STATS range rather than being silently
289/// reported as "no STATS component"; `Ok(None)` is reserved exclusively for a
290/// well-formed TOC that genuinely carries no STATS entry. Crucially, a corrupt
291/// STATS byte-range does NOT discard an already-resolved HEADER offset — a valid
292/// HEADER still decodes EncodingStats authoritatively (issue #2148 blocker).
293fn walk_statistics_toc(
294    input: &[u8],
295) -> (
296    Option<usize>,
297    std::result::Result<Option<StatsComponentBounds>, String>,
298) {
299    if input.len() < 8 {
300        return (
301            None,
302            Err(format!(
303                "Statistics.db too short for a metadata TOC header: {} bytes",
304                input.len()
305            )),
306        );
307    }
308    let num_components = u32::from_be_bytes([input[0], input[1], input[2], input[3]]);
309    if num_components == 0 || num_components > 100 {
310        return (
311            None,
312            Err(format!(
313                "Statistics.db TOC component count out of range: {num_components}"
314            )),
315        );
316    }
317    let toc_start = 8usize;
318    let entry_size = 8usize;
319    let toc_size = match (num_components as usize)
320        .checked_mul(entry_size)
321        .and_then(|n| n.checked_add(toc_start))
322    {
323        Some(n) => n,
324        None => {
325            return (
326                None,
327                Err(format!(
328                    "Statistics.db TOC size overflow for {num_components} components"
329                )),
330            )
331        }
332    };
333    if input.len() < toc_size {
334        return (
335            None,
336            Err(format!(
337                "Statistics.db TOC truncated: need {toc_size} bytes, have {}",
338                input.len()
339            )),
340        );
341    }
342
343    let mut stats_off: Option<usize> = None;
344    let mut header_off: Option<usize> = None;
345    let mut offsets: Vec<usize> = Vec::with_capacity(num_components as usize);
346    for i in 0..num_components as usize {
347        let entry = match i
348            .checked_mul(entry_size)
349            .and_then(|n| n.checked_add(toc_start))
350        {
351            Some(e) => e,
352            None => {
353                // TOC index arithmetic overflow: the header is not reliably
354                // resolvable, so surface the corruption on BOTH channels.
355                return (
356                    header_off,
357                    Err(format!(
358                        "Statistics.db TOC entry offset overflow at index {i}"
359                    )),
360                );
361            }
362        };
363        let ty = u32::from_be_bytes([
364            input[entry],
365            input[entry + 1],
366            input[entry + 2],
367            input[entry + 3],
368        ]);
369        let off = u32::from_be_bytes([
370            input[entry + 4],
371            input[entry + 5],
372            input[entry + 6],
373            input[entry + 7],
374        ]) as usize;
375        offsets.push(off);
376        if ty == METADATA_TYPE_STATS {
377            // Last-wins, matching the historical `stats_component_bounds` walk.
378            stats_off = Some(off);
379        } else if ty == METADATA_TYPE_HEADER && header_off.is_none() {
380            // First-wins, matching the historical
381            // `parse_statistics_toc_for_header_offset` early-return.
382            header_off = Some(off);
383        }
384    }
385
386    // No STATS component in the TOC → nothing to decode (not an error).
387    let Some(start) = stats_off else {
388        return (header_off, Ok(None));
389    };
390
391    // The STATS body ends 4 bytes before the *next* component begins: every
392    // supported Cassandra 5.0 format (`nb`/`oa`/`da`, all `>= na`) writes a
393    // per-component CRC32 between each component body and the next component's
394    // offset (`MetadataSerializer.java:110-111`). Use the smallest component
395    // offset strictly greater than `start`, minus that CRC; when STATS is the last
396    // component, bound by the file end minus the trailing CRC. Both cases exclude
397    // the 4-byte CRC so it is never decoded as metadata.
398    let next_boundary = offsets
399        .iter()
400        .copied()
401        .filter(|&o| o > start)
402        .min()
403        .unwrap_or(input.len());
404    let end = next_boundary.saturating_sub(METADATA_COMPONENT_CRC_LEN);
405
406    // A STATS entry exists but its derived range is invalid (offset past EOF,
407    // inverted, or empty): genuine corruption — fail closed on the STATS channel
408    // rather than silently reporting the unrepaired default or building a cursor
409    // over garbage. The HEADER offset stays resolved and is returned regardless
410    // (issue #2148 blocker: a corrupt STATS range must not nuke a valid HEADER).
411    if start >= end || end > input.len() {
412        return (
413            header_off,
414            Err(format!(
415                "STATS component range invalid: start {start}, derived end {end}, \
416                 Statistics.db {} bytes",
417                input.len()
418            )),
419        );
420    }
421
422    (header_off, Ok(Some(StatsComponentBounds { start, end })))
423}
424
425/// A bounded cursor over the STATS component bytes that fails closed (explicit
426/// error) on any read past the end rather than panicking.
427struct Cursor<'a> {
428    bytes: &'a [u8],
429    pos: usize,
430}
431
432impl<'a> Cursor<'a> {
433    fn new(bytes: &'a [u8]) -> Self {
434        Cursor { bytes, pos: 0 }
435    }
436
437    fn need(&self, n: usize) -> Result<usize> {
438        let end = self
439            .pos
440            .checked_add(n)
441            .ok_or_else(|| Error::Corruption("STATS cursor overflow".to_string()))?;
442        if end > self.bytes.len() {
443            return Err(Error::Corruption(format!(
444                "STATS component truncated: need {n} bytes at offset {} but only {} remain",
445                self.pos,
446                self.bytes.len().saturating_sub(self.pos),
447            )));
448        }
449        Ok(end)
450    }
451
452    fn skip(&mut self, n: usize) -> Result<()> {
453        let end = self.need(n)?;
454        self.pos = end;
455        Ok(())
456    }
457
458    fn read_i32(&mut self) -> Result<i32> {
459        let end = self.need(4)?;
460        let v = i32::from_be_bytes([
461            self.bytes[self.pos],
462            self.bytes[self.pos + 1],
463            self.bytes[self.pos + 2],
464            self.bytes[self.pos + 3],
465        ]);
466        self.pos = end;
467        Ok(v)
468    }
469
470    fn read_i64(&mut self) -> Result<i64> {
471        let end = self.need(8)?;
472        let mut buf = [0u8; 8];
473        buf.copy_from_slice(&self.bytes[self.pos..end]);
474        self.pos = end;
475        Ok(i64::from_be_bytes(buf))
476    }
477
478    fn read_u32(&mut self) -> Result<u32> {
479        let end = self.need(4)?;
480        let v = u32::from_be_bytes([
481            self.bytes[self.pos],
482            self.bytes[self.pos + 1],
483            self.bytes[self.pos + 2],
484            self.bytes[self.pos + 3],
485        ]);
486        self.pos = end;
487        Ok(v)
488    }
489
490    fn read_f64(&mut self) -> Result<f64> {
491        let end = self.need(8)?;
492        let mut buf = [0u8; 8];
493        buf.copy_from_slice(&self.bytes[self.pos..end]);
494        self.pos = end;
495        Ok(f64::from_be_bytes(buf))
496    }
497
498    fn read_u8(&mut self) -> Result<u8> {
499        let end = self.need(1)?;
500        let v = self.bytes[self.pos];
501        self.pos = end;
502        Ok(v)
503    }
504
505    fn read_u16(&mut self) -> Result<u16> {
506        let end = self.need(2)?;
507        let v = u16::from_be_bytes([self.bytes[self.pos], self.bytes[self.pos + 1]]);
508        self.pos = end;
509        Ok(v)
510    }
511
512    /// Read a 16-byte raw UUID (the two `long`s Cassandra writes via
513    /// `UUIDSerializer`: most-significant then least-significant bits, each BE).
514    fn read_uuid(&mut self) -> Result<[u8; 16]> {
515        let end = self.need(16)?;
516        let mut buf = [0u8; 16];
517        buf.copy_from_slice(&self.bytes[self.pos..end]);
518        self.pos = end;
519        Ok(buf)
520    }
521
522    /// Read an unsigned VInt (Cassandra `VIntCoding.readUnsignedVInt`):
523    /// the first byte's leading 1-bits give the number of EXTRA bytes; the
524    /// remaining low bits of the first byte are the high-order value bits.
525    fn read_unsigned_vint(&mut self) -> Result<u64> {
526        let first = self.read_u8()?;
527        let extra = first.leading_ones() as usize;
528        if extra == 0 {
529            return Ok(first as u64);
530        }
531        if extra > 8 {
532            return Err(Error::Corruption(format!(
533                "invalid unsigned VInt: {extra} extra bytes"
534            )));
535        }
536        let mut value: u64 = 0;
537        for _ in 0..extra {
538            let b = self.read_u8()?;
539            value = (value << 8) | b as u64;
540        }
541        // The first byte contributes `8 - extra` low bits (its bits below the
542        // `extra` leading 1s and the separator 0). For extra == 8 there are no
543        // first-byte value bits.
544        if extra < 8 {
545            let mask = (1u8 << (8 - extra)) as u64;
546            // bits below the separator bit
547            let first_bits = first as u64 & (mask - 1);
548            value |= first_bits << (8 * extra);
549        }
550        Ok(value)
551    }
552
553    /// Read `n` raw bytes as an owned `Vec`, bounded by the cursor (fails closed
554    /// on overrun). Used for the clusteringTypes UTF-8 names.
555    fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>> {
556        let end = self.need(n)?;
557        let v = self.bytes[self.pos..end].to_vec();
558        self.pos = end;
559        Ok(v)
560    }
561}
562
563impl ByteSkip for Cursor<'_> {
564    fn read_u8(&mut self) -> Result<u8> {
565        Cursor::read_u8(self)
566    }
567    fn read_u16(&mut self) -> Result<u16> {
568        Cursor::read_u16(self)
569    }
570    fn read_unsigned_vint(&mut self) -> Result<u64> {
571        Cursor::read_unsigned_vint(self)
572    }
573    fn skip(&mut self, n: usize) -> Result<()> {
574        Cursor::skip(self, n)
575    }
576}
577
578/// Skip an `EstimatedHistogram`: `i32 count`, then `count` × (`i64 offset`,
579/// `i64 count`). Self-describing — no column-type knowledge required.
580fn skip_estimated_histogram(c: &mut Cursor) -> Result<()> {
581    let count = c.read_i32()?;
582    if count < 0 {
583        return Err(Error::Corruption(format!(
584            "negative EstimatedHistogram bucket count {count}"
585        )));
586    }
587    let bytes = (count as usize)
588        .checked_mul(16)
589        .ok_or_else(|| Error::Corruption("EstimatedHistogram size overflow".to_string()))?;
590    c.skip(bytes)
591}
592
593/// Skip a `TombstoneHistogram`: `i32 maxBinSize`, `i32 size`, then `size` bins.
594///
595/// `modern` selects the entry width: the modern (`oa`/`da`)
596/// `HistogramSerializer` writes `i64 point + i32 value` (12 bytes); the legacy
597/// (`nb`/`mc`) serializer writes `f64 point + i64 value` (16 bytes). The choice
598/// follows `TombstoneHistogram.getSerializer(version)` — the only format-gated
599/// decision needed to reach `repairedAt`.
600fn skip_tombstone_histogram(c: &mut Cursor, modern: bool) -> Result<()> {
601    let _max_bin_size = c.read_i32()?;
602    let size = c.read_i32()?;
603    if size < 0 {
604        return Err(Error::Corruption(format!(
605            "negative TombstoneHistogram size {size}"
606        )));
607    }
608    let entry_width = if modern { 12 } else { 16 };
609    let bytes = (size as usize)
610        .checked_mul(entry_width)
611        .ok_or_else(|| Error::Corruption("TombstoneHistogram size overflow".to_string()))?;
612    c.skip(bytes)
613}
614
615/// Whether this version uses the modern tombstone-histogram entry encoding.
616///
617/// `TombstoneHistogram.getSerializer` resolves to the modern `HistogramSerializer`
618/// for `oa`+ (incl. BTI `da`) and the legacy serializer for older versions.
619fn uses_modern_tombstone_histogram(gates: &VersionGates) -> bool {
620    match gates {
621        // The oa-only `hasUIntDeletionTime` gate cleanly separates the modern
622        // (oa/da) histogram encoding from the legacy (nb) one.
623        VersionGates::Big(g) => g.has_uint_deletion_time,
624        VersionGates::Bti(_) => true,
625    }
626}
627
628/// The version-gate flags the post-`repairedAt` forward walk needs, projected
629/// out of [`VersionGates`] so the walk depends only on the AUTHORITATIVE parsed
630/// descriptor (no heuristics, #28). Every flag mirrors a single Cassandra
631/// `BigFormat`/`BtiFormat` version gate consumed by
632/// `StatsMetadata.StatsMetadataSerializer.serialize`.
633#[derive(Debug, Clone, Copy)]
634struct RepairWalkGates {
635    /// Modern tombstone-histogram entry width (`oa`+/`da`).
636    modern_histogram: bool,
637    /// `hasImprovedMinMax` — emits clusteringTypes + a covered `Slice` in place
638    /// of the legacy min/max clustering-value lists.
639    has_improved_min_max: bool,
640    /// `hasLegacyMinMax` — emits min/max clustering-value lists (each
641    /// `i32 count` then `count` × vint-length value).
642    has_legacy_min_max: bool,
643    /// `hasPendingRepair` (`na`+/`da`) — the `pendingRepair` nullable-UUID field
644    /// is present in the body.
645    has_pending_repair: bool,
646    /// `hasIsTransient` (`na`+/`da`) — the `isTransient` boolean is present.
647    has_is_transient: bool,
648}
649
650impl RepairWalkGates {
651    fn from(gates: &VersionGates) -> Self {
652        match gates {
653            VersionGates::Big(g) => RepairWalkGates {
654                modern_histogram: g.has_uint_deletion_time,
655                has_improved_min_max: g.has_improved_min_max,
656                has_legacy_min_max: g.has_legacy_min_max,
657                has_pending_repair: g.has_pending_repair,
658                has_is_transient: g.has_is_transient,
659            },
660            VersionGates::Bti(g) => RepairWalkGates {
661                modern_histogram: true,
662                has_improved_min_max: g.has_improved_min_max,
663                has_legacy_min_max: g.has_legacy_min_max,
664                has_pending_repair: g.has_pending_repair,
665                has_is_transient: g.has_is_transient,
666            },
667        }
668    }
669}
670
671/// Skip the legacy min/max clustering-value lists (`hasLegacyMinMax`).
672///
673/// Each list is `i32 count` then `count` × a value written with
674/// `ByteBufferUtil.writeWithShortLength` — an UNSIGNED 16-bit length prefix
675/// (`writeShort`) then the raw bytes (cassandra-5.0.0
676/// `StatsMetadata.StatsMetadataSerializer.serialize` legacy branch). Verified
677/// against a real `nb` `system_schema` fixture (clustering values present).
678fn skip_legacy_min_max(c: &mut Cursor) -> Result<()> {
679    for _ in 0..2 {
680        let count = c.read_i32()?;
681        if count < 0 {
682            return Err(Error::Corruption(format!(
683                "negative legacy min/max clustering count {count}"
684            )));
685        }
686        for _ in 0..count {
687            let len = c.read_u16()? as usize;
688            c.skip(len)?;
689        }
690    }
691    Ok(())
692}
693
694/// Attempt to skip the improvedMinMax block (`hasImprovedMinMax`): a
695/// clusteringTypes list (unsigned-VInt count, then each type a vint-length UTF-8
696/// string) followed by the covered `Slice` (two `ClusteringBound`s, each
697/// `byte kind` + `short size` + `size` comparator-encoded values).
698///
699/// The covered-clustering bound VALUES are RAW comparator-encoded (no per-value
700/// length prefix), exactly as cassandra-5.0.0
701/// `ClusteringPrefix.Serializer.serializeValuesWithoutSize` writes them: 32-value
702/// header batches then each present value's bytes via `AbstractType.writeValue`
703/// (fixed-width raw, or `writeWithVIntLength` for variable types). We skip them
704/// authoritatively by resolving each clustering column's `valueLengthIfFixed()`
705/// from the persisted `clusteringTypes` and mirroring
706/// `ClusteringBoundOrBoundary.Serializer.skipValues` (see [`repair_clustering`]).
707///
708/// Returns `Ok(true)` when the whole block (types + covered Slice) was skipped, so
709/// the walk may continue to `pendingRepair`/`isTransient`. Returns `Ok(false)`
710/// ONLY when a clustering type is not modeled (its fixed-vs-variable nature is
711/// unknown) or a bound advertises more values than the types describe — i.e. the
712/// length of some value cannot be computed. In that case the caller reports the
713/// trailing fields honestly as `Unparsed` rather than guessing (#28). Empty
714/// bounds (`Slice.ALL`, the no-clustering shape) skip cleanly to `Ok(true)`.
715fn try_skip_improved_min_max(c: &mut Cursor) -> Result<bool> {
716    // clusteringTypes list → resolved per-column value layouts. The raw UTF-8
717    // type names are read through the same bounded cursor.
718    let layouts = {
719        // Borrow the cursor mutably inside the closure by reading bytes directly;
720        // read_clustering_type_layouts drives both the vint reads and the name
721        // reads through `c`, but the closure also needs `c`, so we inline the
722        // loop here instead of passing a borrow twice.
723        let count = ByteSkip::read_unsigned_vint(c)? as usize;
724        let mut v = Vec::with_capacity(count.min(64));
725        for _ in 0..count {
726            let len = ByteSkip::read_unsigned_vint(c)? as usize;
727            let bytes = c.read_bytes(len)?;
728            let name = std::str::from_utf8(&bytes).map_err(|_| {
729                Error::Corruption("clusteringTypes entry is not valid UTF-8".to_string())
730            })?;
731            v.push(resolve_clustering_value_layout(name));
732        }
733        v
734    };
735    // coveredClustering = Slice: start bound, then end bound, each skipped per the
736    // resolved layouts. A bool result distinguishes "skipped" from "unmodeled".
737    skip_covered_slice(c, &layouts)
738}
739
740/// Skip the `commitLogIntervals` IntervalSet: `i32 size` then `size` × an
741/// interval (`CommitLogPosition` start + end = 2 × (`i64 segmentId` +
742/// `i32 position`) = 24 bytes per interval).
743fn skip_commit_log_intervals(c: &mut Cursor) -> Result<()> {
744    let size = c.read_i32()?;
745    if size < 0 {
746        return Err(Error::Corruption(format!(
747            "negative commitLogIntervals size {size}"
748        )));
749    }
750    let bytes = (size as usize)
751        .checked_mul(24)
752        .ok_or_else(|| Error::Corruption("commitLogIntervals size overflow".to_string()))?;
753    c.skip(bytes)
754}
755
756/// Decode the repair-state metadata from a raw `Statistics.db` buffer.
757///
758/// `repairedAt` is decoded from the STATS component for every format. When
759/// authoritative [`VersionGates`] are supplied, the full version-gated forward
760/// walk continues past `repairedAt` and decodes `pendingRepair` and
761/// `isTransient` from real bytes too (issue #1021). When `gates` is `None`, the
762/// legacy (nb) tombstone-histogram width is assumed to reach `repairedAt`, and
763/// the two later fields are reported honestly as [`RepairField::Unparsed`] (the
764/// min/max block and commit-log-interval shape are version-dependent and cannot
765/// be traversed without the descriptor).
766///
767/// # Errors
768///
769/// Returns an error only when the STATS component is present but its fields are
770/// truncated/corrupt, OR overrun the STATS component's authoritative end bound —
771/// strict callers can rely on this to fail closed (a truncated body or an
772/// over-long internal length never spills into the trailing CRC or the following
773/// component). A *missing* STATS component is reported as the unrepaired default
774/// with `repaired_at_decoded = false`. A STATS entry that IS present but whose
775/// derived byte range is invalid (offset past EOF, inverted) is treated as
776/// corruption and fails closed.
777pub fn parse_repair_metadata(input: &[u8], gates: Option<&VersionGates>) -> Result<RepairMetadata> {
778    let walk = gates.map(RepairWalkGates::from);
779
780    let Some(bounds) = stats_component_bounds(input)? else {
781        return Ok(RepairMetadata::unrepaired_default());
782    };
783
784    let modern_histogram = walk.map(|w| w.modern_histogram).unwrap_or(false);
785
786    // Bound the cursor over ONLY the STATS component slice (start..end, with the
787    // trailing CRC and following components excluded). Any read past this slice
788    // fails closed with Error::Corruption.
789    let mut c = Cursor::new(&input[bounds.start..bounds.end]);
790
791    // 1-2. estimatedPartitionSize + estimatedCellPerPartitionCount.
792    skip_estimated_histogram(&mut c)?;
793    skip_estimated_histogram(&mut c)?;
794
795    // 3. commitLogUpperBound: i64 segmentId + i32 position.
796    c.skip(8 + 4)?;
797
798    // 4. minTimestamp, maxTimestamp.
799    c.skip(8 + 8)?;
800
801    // 5. min/maxLocalDeletionTime. Both encodings (nb 2× i32, oa/da 2× u32)
802    //    occupy 8 bytes total, so the width does not branch here.
803    c.skip(4 + 4)?;
804
805    // 6. minTTL, maxTTL.
806    c.skip(4 + 4)?;
807
808    // 7. compressionRatio (f64).
809    c.skip(8)?;
810
811    // 8. estimatedTombstoneDropTime (TombstoneHistogram) — the only format-gated
812    //    field width on the path to repairedAt.
813    skip_tombstone_histogram(&mut c, modern_histogram)?;
814
815    // 9. sstableLevel (i32), then repairedAt (i64).
816    let _sstable_level = c.read_i32()?;
817    let repaired_at = c.read_i64()?;
818
819    // Without authoritative gates the post-repairedAt min/max block and the
820    // commit-log-interval shape cannot be traversed; report the two later fields
821    // honestly as Unparsed rather than a fabricated null / false.
822    let Some(walk) = walk else {
823        return Ok(RepairMetadata {
824            repaired_at,
825            pending_repair: RepairField::Unparsed,
826            is_transient: RepairField::Unparsed,
827            repaired_at_decoded: true,
828        });
829    };
830
831    // 10. min/max clustering block. Cassandra serializes the improvedMinMax
832    //     variant when present, otherwise the legacy variant; a version that
833    //     carries neither (pre-mb) writes nothing here.
834    //
835    //     The improvedMinMax covered-clustering Slice carries comparator-encoded
836    //     bound VALUES that this repair-only decoder does not model. When those
837    //     values are present (a table with clustering data) the walk cannot
838    //     safely continue, so the two later fields are reported honestly as
839    //     `Unparsed` rather than mis-decoded.
840    if walk.has_improved_min_max {
841        if !try_skip_improved_min_max(&mut c)? {
842            return Ok(RepairMetadata {
843                repaired_at,
844                pending_repair: RepairField::Unparsed,
845                is_transient: RepairField::Unparsed,
846                repaired_at_decoded: true,
847            });
848        }
849    } else if walk.has_legacy_min_max {
850        skip_legacy_min_max(&mut c)?;
851    }
852
853    // 11. hasLegacyCounterShards (bool).
854    c.skip(1)?;
855
856    // 12. totalColumnsSet (long), totalRows (long).
857    c.skip(8 + 8)?;
858
859    // 13. commitLogLowerBound (CommitLogPosition) + commitLogIntervals
860    //     (IntervalSet). `hasCommitLogLowerBound` is `>= mb` and
861    //     `hasCommitLogIntervals` is `>= mc` in Cassandra; both gates are below
862    //     `na`, so every Cassandra 5.0 format CQLite reads (`nb`/`oa`/`da`) always
863    //     carries both fields. Skip them unconditionally. (The pre-`mc` absent
864    //     case is out of scope — CQLite is a Cassandra 5.0 reader.)
865    c.skip(8 + 4)?; // commitLogLowerBound CommitLogPosition: i64 segmentId + i32 position
866    skip_commit_log_intervals(&mut c)?;
867
868    // 14. pendingRepair: a presence byte (writeBoolean), then a 16-byte UUID
869    //     when present. `hasPendingRepair` is `version.compareTo("na") >= 0`
870    //     (cassandra-5.0.0 `BigFormat`/`BtiFormat`), so it is ALWAYS true for
871    //     every Cassandra 5.0 format CQLite reads (`nb`/`oa`/`da`) and the field
872    //     is always present and decoded. The `else` is unreachable for supported
873    //     formats (pre-`na` BIG `ma`–`me` is out of scope — CQLite is a Cassandra
874    //     5.0 reader); it fails closed via `Unparsed` rather than fabricating a
875    //     value, which `classify_inputs` rejects.
876    let pending_repair = if walk.has_pending_repair {
877        let present = c.read_u8()?;
878        if present != 0 {
879            RepairField::Decoded(Some(c.read_uuid()?))
880        } else {
881            RepairField::Decoded(None)
882        }
883    } else {
884        RepairField::Unparsed
885    };
886
887    // 15. isTransient (bool). Same `>= na` gate as pendingRepair, so always
888    //     present and decoded for supported Cassandra 5.0 formats. The `else` is
889    //     unreachable for `nb`/`oa`/`da`; it fails closed (`Unparsed`).
890    let is_transient = if walk.has_is_transient {
891        RepairField::Decoded(c.read_u8()? != 0)
892    } else {
893        RepairField::Unparsed
894    };
895
896    Ok(RepairMetadata {
897        repaired_at,
898        pending_repair,
899        is_transient,
900        repaired_at_decoded: true,
901    })
902}
903
904/// Authoritative per-SSTable row/partition counts decoded from the STATS
905/// component of `Statistics.db` (issue #944).
906///
907/// Both fields come straight from cassandra-5.0.0
908/// `StatsMetadata.StatsMetadataSerializer.serialize` — no heuristics (#28):
909///
910///   * `partition_count` is the sum of the bucket counts of the
911///     `estimatedPartitionSize` `EstimatedHistogram` (the FIRST STATS field). That
912///     histogram records one observation per partition, so Σ counts is exactly the
913///     SSTable's partition count — the same value `SSTableReader` exposes via
914///     `getEstimatedPartitionSize().count()`. It is reachable by a fully
915///     self-describing walk and needs NO version gates.
916///   * `total_rows` is the `totalRows` `long` written near the end of the STATS
917///     body (field 12). Reaching it requires the version-gated forward walk
918///     (`improvedMinMax`/`legacyMinMax` block + commit-log intervals), so it is
919///     `Some` only when authoritative [`VersionGates`] are supplied AND the walk
920///     could traverse the min/max block. For a clustered table whose covered-Slice
921///     bound values are not modeled, `total_rows` is honestly `None` rather than a
922///     guessed value.
923#[derive(Debug, Clone, Copy, PartialEq, Eq)]
924pub struct TableCounts {
925    /// Σ `estimatedPartitionSize` histogram bucket counts = partition count.
926    pub partition_count: u64,
927    /// `totalRows` from the STATS body, when the gated walk could reach it.
928    pub total_rows: Option<u64>,
929}
930
931/// Decode authoritative [`TableCounts`] from a raw `Statistics.db` buffer.
932///
933/// `partition_count` is always decoded (self-describing leading histogram).
934/// `total_rows` is decoded only when `gates` is `Some` and the version-gated walk
935/// to field 12 succeeds; otherwise it is `None` (never fabricated).
936///
937/// # Errors
938///
939/// Returns `Error::Corruption` when the STATS component is present but truncated
940/// or structurally invalid (mirrors [`parse_repair_metadata`] — fails closed). A
941/// `Statistics.db` with NO STATS component yields all-zero counts
942/// (`partition_count == 0`, `total_rows == None`).
943pub fn read_table_counts(input: &[u8], gates: Option<&VersionGates>) -> Result<TableCounts> {
944    read_table_counts_from_bounds(input, stats_component_bounds(input)?, gates)
945}
946
947/// [`read_table_counts`] over a `Statistics.db` TOC that was already parsed once
948/// (issue #2148): reuses the STATS bounds from `toc` instead of re-walking the
949/// TOC, so a single metadata parse walks the TOC exactly once.
950pub(crate) fn read_table_counts_with_toc(
951    input: &[u8],
952    toc: &StatisticsToc,
953    gates: Option<&VersionGates>,
954) -> Result<TableCounts> {
955    read_table_counts_from_bounds(input, toc.stats_bounds()?, gates)
956}
957
958fn read_table_counts_from_bounds(
959    input: &[u8],
960    bounds: Option<StatsComponentBounds>,
961    gates: Option<&VersionGates>,
962) -> Result<TableCounts> {
963    let walk = gates.map(RepairWalkGates::from);
964
965    let Some(bounds) = bounds else {
966        return Ok(TableCounts {
967            partition_count: 0,
968            total_rows: None,
969        });
970    };
971
972    let modern_histogram = walk.map(|w| w.modern_histogram).unwrap_or(false);
973    let mut c = Cursor::new(&input[bounds.start..bounds.end]);
974
975    // 1. estimatedPartitionSize histogram: read it (don't skip) so we can sum the
976    //    per-partition bucket counts. Σ counts = partition count (authoritative).
977    let partition_count = sum_estimated_histogram_counts(&mut c)?;
978
979    // 2. estimatedCellPerPartitionCount histogram — skip.
980    skip_estimated_histogram(&mut c)?;
981
982    // Without authoritative gates the path to totalRows (the gated min/max block)
983    // cannot be traversed; report partition_count alone and total_rows = None.
984    let Some(walk) = walk else {
985        return Ok(TableCounts {
986            partition_count,
987            total_rows: None,
988        });
989    };
990
991    // 3-8. commitLogUpperBound, timestamps, deletion times, TTLs, compressionRatio,
992    //       tombstone histogram — same fixed/gated skips as the repair walk.
993    c.skip(8 + 4)?; // commitLogUpperBound: i64 segmentId + i32 position
994    c.skip(8 + 8)?; // minTimestamp, maxTimestamp
995    c.skip(4 + 4)?; // min/maxLocalDeletionTime
996    c.skip(4 + 4)?; // minTTL, maxTTL
997    c.skip(8)?; // compressionRatio (f64)
998    skip_tombstone_histogram(&mut c, modern_histogram)?;
999
1000    // 9. sstableLevel (i32), repairedAt (i64).
1001    c.skip(4 + 8)?;
1002
1003    // 10. version-gated min/max clustering block. The improvedMinMax covered-Slice
1004    //     bound values are not always modeled; when they cannot be skipped, totalRows
1005    //     is unreachable and reported honestly as None.
1006    if walk.has_improved_min_max {
1007        if !try_skip_improved_min_max(&mut c)? {
1008            return Ok(TableCounts {
1009                partition_count,
1010                total_rows: None,
1011            });
1012        }
1013    } else if walk.has_legacy_min_max {
1014        skip_legacy_min_max(&mut c)?;
1015    }
1016
1017    // 11. hasLegacyCounterShards (bool).
1018    c.skip(1)?;
1019
1020    // 12. totalColumnsSet (long), then totalRows (long) — READ totalRows.
1021    c.skip(8)?; // totalColumnsSet
1022    let total_rows = c.read_i64()?;
1023    let total_rows = u64::try_from(total_rows).ok();
1024
1025    Ok(TableCounts {
1026        partition_count,
1027        total_rows,
1028    })
1029}
1030
1031/// Read an `EstimatedHistogram` and return the SUM of its bucket counts (`i64`
1032/// each). Layout: `i32 bucketCount`, then `bucketCount` × (`i64 offset`,
1033/// `i64 count`). Σ counts = number of observations (one per partition for the
1034/// estimatedPartitionSize histogram). Saturating add: a real SSTable never
1035/// overflows u64, but never wrap into a tiny count that would mislead the gate.
1036fn sum_estimated_histogram_counts(c: &mut Cursor) -> Result<u64> {
1037    let count = c.read_i32()?;
1038    if count < 0 {
1039        return Err(Error::Corruption(format!(
1040            "negative EstimatedHistogram bucket count {count}"
1041        )));
1042    }
1043    let mut sum: u64 = 0;
1044    for _ in 0..count {
1045        let _offset = c.read_i64()?;
1046        let bucket = c.read_i64()?;
1047        if bucket < 0 {
1048            return Err(Error::Corruption(format!(
1049                "negative EstimatedHistogram bucket value {bucket}"
1050            )));
1051        }
1052        sum = sum.saturating_add(bucket as u64);
1053    }
1054    Ok(sum)
1055}
1056
1057/// Cassandra's canonical "no deletion" local-deletion-time sentinel,
1058/// normalized to `i64` for both legacy (nb) and modern (oa/da) encodings.
1059///
1060/// `LivenessInfo.NO_DELETION_TIME` is `Cell.MAX_DELETION_TIME` =
1061/// `Integer.MAX_VALUE` for legacy (signed `i32`) SSTables, and `0xFFFF_FFFF`
1062/// (the `u32` saturation value) for modern SSTables that store the field as an
1063/// unsigned 32-bit integer. `sstablemetadata` prints `Long.MAX_VALUE`
1064/// (`9223372036854775807`) for "no tombstones"; we normalize to that here so
1065/// the decoded `max_local_deletion_time` matches the reference tool's printed
1066/// integer in BOTH cases.
1067const NO_DELETION_TIME: i64 = i64::MAX;
1068
1069/// Additional STATS-component facts decoded by [`parse_stats_extras`] that are
1070/// not part of the repair-coordination metadata: the SSTable's maximum local
1071/// deletion time and the estimated tombstone-drop-times histogram.
1072///
1073/// Decoded best-effort: a `Statistics.db` that parses today must keep parsing,
1074/// so callers treat an `Err` from [`parse_stats_extras`] as "leave the existing
1075/// placeholders" rather than propagating it.
1076#[derive(Debug, Clone)]
1077pub struct StatsExtras {
1078    /// SSTable `maxLocalDeletionTime`, normalized so that the version-specific
1079    /// "no tombstones" sentinel (`i32::MAX` for nb, `0xFFFF_FFFF` for oa/da)
1080    /// maps to [`NO_DELETION_TIME`] (`i64::MAX`); any real deletion time is the
1081    /// seconds-since-epoch value as `i64`.
1082    pub max_local_deletion_time: i64,
1083    /// `estimatedTombstoneDropTime` histogram as `(point, count)` pairs. Empty
1084    /// when the histogram carries no bins (the common case for SSTables with no
1085    /// tombstones).
1086    pub tombstone_drop_times: Vec<(i64, u64)>,
1087    /// Authoritative SSTable `maxTimestamp` (write timestamp, microseconds)
1088    /// decoded from STATS field 4 (issue #1729). `None` when the SSTable
1089    /// carries no live timestamp — Cassandra's `MetadataCollector` seeds the
1090    /// tracker with `Long.MIN_VALUE`, so an on-disk value of [`i64::MIN`] is the
1091    /// "no timestamps recorded" sentinel and is surfaced fail-closed as `None`
1092    /// (consumers that require a real max — e.g. the #1388 drop gate — must NOT
1093    /// proceed as if a max were known). Any other value is the true maximum.
1094    ///
1095    /// This intentionally replaces the historical `max_timestamp = min_timestamp`
1096    /// placeholder in [`crate::parser::enhanced_statistics_parser`], which
1097    /// silently reported the MIN write timestamp as the max.
1098    pub max_timestamp: Option<i64>,
1099    /// Authoritative SSTable `maxTTL` (seconds) decoded from STATS field 9
1100    /// (issue #1537). Cassandra's `MetadataCollector` seeds the TTL tracker with
1101    /// `Cell.NO_TTL` (`0`) and updates it only from EXPIRING cells, so a maxTTL of
1102    /// `0` means "no expiring cells" and is surfaced fail-closed as `None`. Any
1103    /// positive value is the true maximum TTL across the SSTable's expiring cells.
1104    /// The compaction TTL-expiry LDT floor (issue #1537,
1105    /// [`crate::storage::write_engine::merge::compute_expiry_ttl_ldt_floor`]) needs
1106    /// this authoritative maximum: the enhanced (nb) parser only decodes the
1107    /// EncodingStats `minTTL` baseline and honestly leaves `max_ttl = None`, so
1108    /// this best-effort STATS-extras walk is the only source of the true maximum.
1109    pub max_ttl: Option<i64>,
1110}
1111
1112impl Default for StatsExtras {
1113    /// The default reported when no STATS component is locatable: "no
1114    /// tombstones" max-LDT ([`NO_DELETION_TIME`]), an empty histogram, and no
1115    /// authoritative `maxTimestamp` (fail-closed `None`).
1116    fn default() -> Self {
1117        StatsExtras {
1118            max_local_deletion_time: NO_DELETION_TIME,
1119            tombstone_drop_times: Vec::new(),
1120            max_timestamp: None,
1121            max_ttl: None,
1122        }
1123    }
1124}
1125
1126/// Decode the SSTable `maxLocalDeletionTime` and the estimated
1127/// tombstone-drop-times histogram from a raw `Statistics.db` buffer.
1128///
1129/// This walks the same self-describing leading STATS fields as
1130/// [`parse_repair_metadata`], but reads (rather than skips) field 5
1131/// (min/maxLocalDeletionTime) and field 8 (the tombstone histogram).
1132///
1133/// When `gates` is `None`, the legacy (nb) encoding is assumed for BOTH the
1134/// max-LDT sentinel comparison and the histogram entry width (nb-compatible
1135/// default, matching the rest of the minimal Statistics parser and the
1136/// `merge.rs` caller).
1137///
1138/// # Returns
1139///
1140/// * `Ok(StatsExtras::default())` — max-LDT = [`NO_DELETION_TIME`], empty
1141///   histogram — when the buffer carries no STATS component (nothing to decode).
1142/// * `Ok(extras)` with the decoded facts otherwise.
1143///
1144/// # Errors
1145///
1146/// Returns an error when the STATS component is present but its leading
1147/// (self-describing) fields are truncated/corrupt or overrun the component's
1148/// authoritative end bound. Best-effort callers must treat this as "leave the
1149/// existing placeholders" and MUST NOT propagate it (a `Statistics.db` that
1150/// parses today must keep parsing).
1151pub fn parse_stats_extras(input: &[u8], gates: Option<&VersionGates>) -> Result<StatsExtras> {
1152    parse_stats_extras_from_bounds(input, stats_component_bounds(input)?, gates)
1153}
1154
1155/// [`parse_stats_extras`] over a `Statistics.db` TOC that was already parsed once
1156/// (issue #2148): reuses the STATS bounds from `toc` instead of re-walking the
1157/// TOC, so a single metadata parse walks the TOC exactly once.
1158pub(crate) fn parse_stats_extras_with_toc(
1159    input: &[u8],
1160    toc: &StatisticsToc,
1161    gates: Option<&VersionGates>,
1162) -> Result<StatsExtras> {
1163    parse_stats_extras_from_bounds(input, toc.stats_bounds()?, gates)
1164}
1165
1166fn parse_stats_extras_from_bounds(
1167    input: &[u8],
1168    bounds: Option<StatsComponentBounds>,
1169    gates: Option<&VersionGates>,
1170) -> Result<StatsExtras> {
1171    let Some(bounds) = bounds else {
1172        // No STATS component → nothing to decode. Report the canonical
1173        // "no tombstones" default rather than failing.
1174        return Ok(StatsExtras::default());
1175    };
1176
1177    let modern = gates.map(uses_modern_tombstone_histogram).unwrap_or(false);
1178
1179    // Bound the cursor over ONLY the STATS component slice (start..end, CRC and
1180    // following components excluded). Any read past this slice fails closed.
1181    let mut c = Cursor::new(&input[bounds.start..bounds.end]);
1182
1183    // 1-2. estimatedPartitionSize + estimatedCellPerPartitionCount.
1184    skip_estimated_histogram(&mut c)?;
1185    skip_estimated_histogram(&mut c)?;
1186
1187    // 3. commitLogUpperBound: i64 segmentId + i32 position.
1188    c.skip(8 + 4)?;
1189
1190    // 4. minTimestamp, maxTimestamp (both i64 BE microseconds). Read (rather
1191    //    than skip) maxTimestamp: it is the authoritative SSTable max write
1192    //    timestamp (issue #1729). Both encodings (nb / oa / da) lay these out
1193    //    identically here, so no version gating is needed for this field.
1194    c.skip(8)?; // minTimestamp
1195    let raw_max_timestamp = c.read_i64()?;
1196    let max_timestamp = decode_max_timestamp(raw_max_timestamp);
1197
1198    // 5. min/maxLocalDeletionTime. Width is 4 bytes each in both encodings; the
1199    //    signedness/sentinel differs by version (handled below).
1200    let _min_ldt = c.read_u32()?;
1201    let raw_max_ldt = c.read_u32()?;
1202    let max_local_deletion_time = decode_max_local_deletion_time(raw_max_ldt, modern);
1203
1204    // 6. minTTL (skip), maxTTL (read). Both are `int` (4 bytes BE) in every
1205    //    encoding (nb / oa / da). maxTTL is the authoritative maximum TTL across
1206    //    the SSTable's expiring cells (issue #1537); `0` (Cassandra `Cell.NO_TTL`,
1207    //    the tracker seed) means "no expiring cells" and is surfaced as `None`.
1208    c.skip(4)?; // minTTL
1209    let raw_max_ttl = c.read_i32()?;
1210    let max_ttl = if raw_max_ttl > 0 {
1211        Some(i64::from(raw_max_ttl))
1212    } else {
1213        None
1214    };
1215
1216    // 7. compressionRatio (f64).
1217    c.read_f64()?;
1218
1219    // 8. estimatedTombstoneDropTime (TombstoneHistogram).
1220    let tombstone_drop_times = read_tombstone_histogram(&mut c, modern)?;
1221
1222    Ok(StatsExtras {
1223        max_local_deletion_time,
1224        tombstone_drop_times,
1225        max_timestamp,
1226        max_ttl,
1227    })
1228}
1229
1230/// Normalize a raw 4-byte `maxLocalDeletionTime` to a canonical `i64`.
1231///
1232/// * modern (oa/da): the field is an unsigned `u32`; the sentinel is
1233///   `0xFFFF_FFFF`.
1234/// * legacy (nb): the field is a signed `i32`; the sentinel is `i32::MAX`.
1235///
1236/// The version sentinel maps to [`NO_DELETION_TIME`] (`i64::MAX`); any real
1237/// deletion time (always `< 2^31` seconds-since-epoch) is returned as its
1238/// integer value.
1239/// Cassandra's `MetadataCollector` seeds its timestamp `MinMaxLongTracker` with
1240/// `Long.MIN_VALUE` for the max; an SSTable that recorded no live write
1241/// timestamp serializes that sentinel verbatim to STATS field 4. We treat it as
1242/// "no authoritative maxTimestamp" rather than a real (nonsensical) maximum.
1243pub(crate) const NO_MAX_TIMESTAMP_SENTINEL: i64 = i64::MIN;
1244
1245/// Normalize a raw STATS `maxTimestamp` (i64 BE microseconds) into an
1246/// authoritative value, or `None` when the SSTable recorded no write timestamp.
1247///
1248/// The `Long.MIN_VALUE` sentinel maps to `None` (fail-closed: consumers that
1249/// require a real max must not proceed as if one were known). Every other value
1250/// — including a valid large or negative real timestamp — is returned verbatim.
1251///
1252/// Shared by both parsers (enhanced nb walk here, and the legacy fixed-width
1253/// `parse_timestamp_statistics`, issue #1653) so the sentinel→`None` mapping
1254/// never drifts between paths.
1255pub(crate) fn decode_max_timestamp(raw: i64) -> Option<i64> {
1256    if raw == NO_MAX_TIMESTAMP_SENTINEL {
1257        None
1258    } else {
1259        Some(raw)
1260    }
1261}
1262
1263fn decode_max_local_deletion_time(raw: u32, modern: bool) -> i64 {
1264    if modern {
1265        if raw == u32::MAX {
1266            NO_DELETION_TIME
1267        } else {
1268            raw as i64
1269        }
1270    } else {
1271        let signed = raw as i32;
1272        if signed == i32::MAX {
1273            NO_DELETION_TIME
1274        } else {
1275            signed as i64
1276        }
1277    }
1278}
1279
1280/// Read a `TombstoneHistogram` as `(point, count)` pairs.
1281///
1282/// Header: `i32 maxBinSize`, `i32 size` (bin count, must be `>= 0`). Each bin is
1283/// `f64 point + i64 value` (16 bytes) for legacy (nb), or `i64 point + i32 value`
1284/// (12 bytes) for modern (oa/da). The legacy `f64` point is cast to `i64`
1285/// (Cassandra rounds drop-times to integer bucket boundaries, so the cast is
1286/// exact). An empty histogram yields an empty `Vec`.
1287fn read_tombstone_histogram(c: &mut Cursor, modern: bool) -> Result<Vec<(i64, u64)>> {
1288    let _max_bin_size = c.read_i32()?;
1289    let size = c.read_i32()?;
1290    if size < 0 {
1291        return Err(Error::Corruption(format!(
1292            "negative TombstoneHistogram size {size}"
1293        )));
1294    }
1295    // Bound the allocation by the bytes actually present BEFORE reserving:
1296    // a corrupt Statistics.db can advertise a huge positive `size`, and
1297    // `Vec::with_capacity(size)` would otherwise attempt a massive (or aborting)
1298    // allocation. Each bin is `entry_width` bytes; validate that all bins fit in
1299    // the remaining cursor span (fail-closed via `need`) so the reserve below is
1300    // capped by real data, not the untrusted header.
1301    let entry_width = if modern { 12usize } else { 16usize };
1302    let required = (size as usize)
1303        .checked_mul(entry_width)
1304        .ok_or_else(|| Error::Corruption("TombstoneHistogram size overflow".to_string()))?;
1305    c.need(required)?;
1306    let mut out = Vec::with_capacity(size as usize);
1307    for _ in 0..size {
1308        if modern {
1309            let point = c.read_i64()?;
1310            let value = c.read_i32()?;
1311            out.push((point, value as u64));
1312        } else {
1313            let point = c.read_f64()?;
1314            let value = c.read_i64()?;
1315            out.push((point as i64, value as u64));
1316        }
1317    }
1318    Ok(out)
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use crate::storage::sstable::version_gate::BigVersionGates;
1325
1326    /// Build a minimal but valid STATS component (through `repairedAt`) plus a
1327    /// 4-component TOC pointing at it, so the decoder can be exercised in-memory
1328    /// without a fetched fixture.
1329    fn synthetic_statistics(modern_histogram: bool, repaired_at: i64) -> Vec<u8> {
1330        // --- STATS body ---
1331        let mut stats = Vec::new();
1332        let est_hist = |b: &mut Vec<u8>| {
1333            b.extend_from_slice(&0i32.to_be_bytes()); // bucket count 0 (self-describing)
1334        };
1335        est_hist(&mut stats); // estimatedPartitionSize
1336        est_hist(&mut stats); // estimatedCellPerPartitionCount
1337        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogUpperBound segmentId
1338        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogUpperBound position
1339        stats.extend_from_slice(&100i64.to_be_bytes()); // minTimestamp
1340        stats.extend_from_slice(&200i64.to_be_bytes()); // maxTimestamp
1341        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // minLocalDeletionTime
1342        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // maxLocalDeletionTime
1343        stats.extend_from_slice(&0i32.to_be_bytes()); // minTTL
1344        stats.extend_from_slice(&0i32.to_be_bytes()); // maxTTL
1345        stats.extend_from_slice(&(-1.0f64).to_be_bytes()); // compressionRatio
1346                                                           // TombstoneHistogram: empty (maxBinSize=0, size=0) regardless of width
1347        stats.extend_from_slice(&0i32.to_be_bytes());
1348        stats.extend_from_slice(&0i32.to_be_bytes());
1349        let _ = modern_histogram; // empty histogram has identical bytes for both
1350        stats.extend_from_slice(&0i32.to_be_bytes()); // sstableLevel
1351        stats.extend_from_slice(&repaired_at.to_be_bytes()); // repairedAt
1352
1353        // --- TOC: 4 components, STATS (type 2) points at the body ---
1354        // Layout: [u32 count][u32 marker][4 × (u32 type, u32 offset)][stats][crc]
1355        // The three non-STATS components are placed BEFORE the STATS body (with
1356        // tiny one-byte bodies) so STATS is the last component, and its end is
1357        // derived as `file_len - trailing CRC`. This exercises the
1358        // last-component bound path.
1359        let toc_len = 4 + 4 + 4 * 8; // count + marker + entries (no trailing acc here)
1360                                     // 3 one-byte placeholder bodies precede STATS.
1361        let comp0_off = toc_len; // HEADER-ish placeholder
1362        let comp1_off = comp0_off + 1;
1363        let comp3_off = comp1_off + 1;
1364        let stats_off = comp3_off + 1;
1365        let mut out = Vec::new();
1366        out.extend_from_slice(&4u32.to_be_bytes()); // num components
1367        out.extend_from_slice(&0u32.to_be_bytes()); // marker (unused by this decoder)
1368        for (ty, off) in [
1369            (0u32, comp0_off as u32),
1370            (1u32, comp1_off as u32),
1371            (2u32, stats_off as u32), // STATS (last by offset)
1372            (3u32, comp3_off as u32),
1373        ] {
1374            out.extend_from_slice(&ty.to_be_bytes());
1375            out.extend_from_slice(&off.to_be_bytes());
1376        }
1377        // 3 placeholder component bodies (1 byte each).
1378        out.extend_from_slice(&[0u8, 0u8, 0u8]);
1379        debug_assert_eq!(out.len(), stats_off);
1380        out.extend_from_slice(&stats);
1381        out.extend_from_slice(&0u32.to_be_bytes()); // trailing metadata CRC
1382        out
1383    }
1384
1385    /// Build a STATS component (through `repairedAt`) wrapped in a 4-component
1386    /// TOC (STATS last), with caller-controlled `maxLocalDeletionTime` (raw
1387    /// 4-byte field, written verbatim) and tombstone-histogram bins. The bins
1388    /// are written using the legacy (16-byte: `f64 point + i64 value`) or
1389    /// modern (12-byte: `i64 point + i32 value`) layout per `modern`.
1390    fn synthetic_statistics_extras(modern: bool, raw_max_ldt: u32, bins: &[(i64, u64)]) -> Vec<u8> {
1391        // Default fixture: minTimestamp=100, maxTimestamp=200.
1392        synthetic_statistics_extras_ts(modern, raw_max_ldt, bins, 100, 200, 0)
1393    }
1394
1395    /// Same as [`synthetic_statistics_extras`] but with explicit min/max write
1396    /// timestamps (STATS field 4), for exercising `decode_max_timestamp` (#1729),
1397    /// and an explicit `max_ttl` (STATS field 9, issue #1537).
1398    fn synthetic_statistics_extras_ts(
1399        modern: bool,
1400        raw_max_ldt: u32,
1401        bins: &[(i64, u64)],
1402        min_timestamp: i64,
1403        max_timestamp: i64,
1404        max_ttl: i32,
1405    ) -> Vec<u8> {
1406        let mut stats = Vec::new();
1407        // estimatedPartitionSize + estimatedCellPerPartitionCount (empty).
1408        stats.extend_from_slice(&0i32.to_be_bytes());
1409        stats.extend_from_slice(&0i32.to_be_bytes());
1410        // commitLogUpperBound: i64 segmentId + i32 position.
1411        stats.extend_from_slice(&(-1i64).to_be_bytes());
1412        stats.extend_from_slice(&0i32.to_be_bytes());
1413        // minTimestamp, maxTimestamp.
1414        stats.extend_from_slice(&min_timestamp.to_be_bytes());
1415        stats.extend_from_slice(&max_timestamp.to_be_bytes());
1416        // minLocalDeletionTime (any), maxLocalDeletionTime (verbatim raw u32).
1417        stats.extend_from_slice(&0u32.to_be_bytes());
1418        stats.extend_from_slice(&raw_max_ldt.to_be_bytes());
1419        // minTTL, maxTTL.
1420        stats.extend_from_slice(&0i32.to_be_bytes());
1421        stats.extend_from_slice(&max_ttl.to_be_bytes());
1422        // compressionRatio (f64).
1423        stats.extend_from_slice(&(-1.0f64).to_be_bytes());
1424        // TombstoneHistogram: maxBinSize, size, then bins.
1425        stats.extend_from_slice(&100i32.to_be_bytes()); // maxBinSize
1426        stats.extend_from_slice(&(bins.len() as i32).to_be_bytes());
1427        for &(point, value) in bins {
1428            if modern {
1429                stats.extend_from_slice(&point.to_be_bytes()); // i64 point
1430                stats.extend_from_slice(&(value as i32).to_be_bytes()); // i32 value
1431            } else {
1432                stats.extend_from_slice(&(point as f64).to_be_bytes()); // f64 point
1433                stats.extend_from_slice(&(value as i64).to_be_bytes()); // i64 value
1434            }
1435        }
1436        // sstableLevel, repairedAt.
1437        stats.extend_from_slice(&0i32.to_be_bytes());
1438        stats.extend_from_slice(&0i64.to_be_bytes());
1439
1440        // TOC: 4 components, STATS (type 2) last by offset.
1441        let toc_len = 4 + 4 + 4 * 8;
1442        let comp0_off = toc_len;
1443        let comp1_off = comp0_off + 1;
1444        let comp3_off = comp1_off + 1;
1445        let stats_off = comp3_off + 1;
1446        let mut out = Vec::new();
1447        out.extend_from_slice(&4u32.to_be_bytes());
1448        out.extend_from_slice(&0u32.to_be_bytes());
1449        for (ty, off) in [
1450            (0u32, comp0_off as u32),
1451            (1u32, comp1_off as u32),
1452            (2u32, stats_off as u32),
1453            (3u32, comp3_off as u32),
1454        ] {
1455            out.extend_from_slice(&ty.to_be_bytes());
1456            out.extend_from_slice(&off.to_be_bytes());
1457        }
1458        out.extend_from_slice(&[0u8, 0u8, 0u8]);
1459        debug_assert_eq!(out.len(), stats_off);
1460        out.extend_from_slice(&stats);
1461        out.extend_from_slice(&0u32.to_be_bytes()); // trailing CRC
1462        out
1463    }
1464
1465    fn nb_gates() -> VersionGates {
1466        VersionGates::Big(BigVersionGates::from_version("nb").expect("nb gates"))
1467    }
1468
1469    fn oa_gates() -> VersionGates {
1470        VersionGates::Big(BigVersionGates::from_version("oa").expect("oa gates"))
1471    }
1472
1473    fn da_gates() -> VersionGates {
1474        use crate::storage::sstable::version_gate::BtiVersionGates;
1475        VersionGates::Bti(BtiVersionGates::from_version("da").expect("da gates"))
1476    }
1477
1478    /// Append an unsigned VInt (Cassandra `VIntCoding.writeUnsignedVInt`) so the
1479    /// full-walk synthetic builders match the bytes [`Cursor::read_unsigned_vint`]
1480    /// consumes. Values used by these tests fit in `< 128`, encoded as one byte.
1481    fn push_uvint(buf: &mut Vec<u8>, v: u64) {
1482        assert!(
1483            v < 0x80,
1484            "test uvint helper only handles single-byte values"
1485        );
1486        buf.push(v as u8);
1487    }
1488
1489    /// Build a complete STATS body through `isTransient` for the legacy (`nb`)
1490    /// layout (legacy min/max clustering lists; pendingRepair UUID + transient
1491    /// flag carried by `na`+). Wrapped in a STATS-last 2-component TOC + CRC.
1492    fn synthetic_full_nb(
1493        repaired_at: i64,
1494        pending_repair: Option<[u8; 16]>,
1495        is_transient: bool,
1496    ) -> Vec<u8> {
1497        let mut stats = Vec::new();
1498        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedPartitionSize
1499        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedCellPerPartitionCount
1500        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogUpperBound segmentId
1501        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogUpperBound position
1502        stats.extend_from_slice(&100i64.to_be_bytes()); // minTimestamp
1503        stats.extend_from_slice(&200i64.to_be_bytes()); // maxTimestamp
1504        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // minLocalDeletionTime
1505        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // maxLocalDeletionTime
1506        stats.extend_from_slice(&0i32.to_be_bytes()); // minTTL
1507        stats.extend_from_slice(&0i32.to_be_bytes()); // maxTTL
1508        stats.extend_from_slice(&(-1.0f64).to_be_bytes()); // compressionRatio
1509        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram maxBinSize
1510        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram size
1511        stats.extend_from_slice(&0i32.to_be_bytes()); // sstableLevel
1512        stats.extend_from_slice(&repaired_at.to_be_bytes()); // repairedAt
1513                                                             // legacy min/max clustering lists (both empty: count 0).
1514        stats.extend_from_slice(&0i32.to_be_bytes());
1515        stats.extend_from_slice(&0i32.to_be_bytes());
1516        stats.push(0x00); // hasLegacyCounterShards
1517        stats.extend_from_slice(&0u64.to_be_bytes()); // totalColumnsSet
1518        stats.extend_from_slice(&0u64.to_be_bytes()); // totalRows
1519        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogLowerBound segmentId
1520        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogLowerBound position
1521        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogIntervals size = 0
1522                                                      // pendingRepair: presence byte + optional UUID.
1523        match pending_repair {
1524            Some(uuid) => {
1525                stats.push(0x01);
1526                stats.extend_from_slice(&uuid);
1527            }
1528            None => stats.push(0x00),
1529        }
1530        stats.push(if is_transient { 0x01 } else { 0x00 }); // isTransient
1531        wrap_stats_last(stats)
1532    }
1533
1534    /// Build a complete STATS body through `isTransient` for the `da` (BtiFormat)
1535    /// layout (improvedMinMax: clusteringTypes + covered Slice). One clustering
1536    /// type and an empty covered slice exercise the improved-min/max walk.
1537    fn synthetic_full_da(
1538        repaired_at: i64,
1539        pending_repair: Option<[u8; 16]>,
1540        is_transient: bool,
1541    ) -> Vec<u8> {
1542        let mut stats = Vec::new();
1543        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedPartitionSize
1544        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedCellPerPartitionCount
1545        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogUpperBound segmentId
1546        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogUpperBound position
1547        stats.extend_from_slice(&100i64.to_be_bytes()); // minTimestamp
1548        stats.extend_from_slice(&200i64.to_be_bytes()); // maxTimestamp
1549        stats.extend_from_slice(&u32::MAX.to_be_bytes()); // minLocalDeletionTime (uint)
1550        stats.extend_from_slice(&u32::MAX.to_be_bytes()); // maxLocalDeletionTime (uint)
1551        stats.extend_from_slice(&0i32.to_be_bytes()); // minTTL
1552        stats.extend_from_slice(&0i32.to_be_bytes()); // maxTTL
1553        stats.extend_from_slice(&(-1.0f64).to_be_bytes()); // compressionRatio
1554        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram maxBinSize (modern)
1555        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram size (modern)
1556        stats.extend_from_slice(&0i32.to_be_bytes()); // sstableLevel
1557        stats.extend_from_slice(&repaired_at.to_be_bytes()); // repairedAt
1558                                                             // improvedMinMax: clusteringTypes list (1 type) + covered Slice.
1559        push_uvint(&mut stats, 1); // clusteringTypes count
1560        let ty = b"org.apache.cassandra.db.marshal.Int32Type";
1561        push_uvint(&mut stats, ty.len() as u64);
1562        stats.extend_from_slice(ty);
1563        // covered Slice: start bound (kind=1, size=0), end bound (kind=6, size=0).
1564        stats.push(1);
1565        stats.extend_from_slice(&0u16.to_be_bytes());
1566        stats.push(6);
1567        stats.extend_from_slice(&0u16.to_be_bytes());
1568        stats.push(0x00); // hasLegacyCounterShards
1569        stats.extend_from_slice(&0u64.to_be_bytes()); // totalColumnsSet
1570        stats.extend_from_slice(&0u64.to_be_bytes()); // totalRows
1571        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogLowerBound segmentId
1572        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogLowerBound position
1573        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogIntervals size = 0
1574        match pending_repair {
1575            Some(uuid) => {
1576                stats.push(0x01);
1577                stats.extend_from_slice(&uuid);
1578            }
1579            None => stats.push(0x00),
1580        }
1581        stats.push(if is_transient { 0x01 } else { 0x00 }); // isTransient
1582        wrap_stats_last(stats)
1583    }
1584
1585    /// Wrap a STATS body in a 2-component TOC (STATS type 2 last by offset) plus a
1586    /// trailing 4-byte CRC, so the decoder's end bound is `file_len - CRC`.
1587    fn wrap_stats_last(stats: Vec<u8>) -> Vec<u8> {
1588        let toc_len = 4 + 4 + 2 * 8;
1589        let comp0_off = toc_len;
1590        let stats_off = comp0_off + 1;
1591        let mut out = Vec::new();
1592        out.extend_from_slice(&2u32.to_be_bytes());
1593        out.extend_from_slice(&0u32.to_be_bytes());
1594        for (ty, off) in [(0u32, comp0_off as u32), (2u32, stats_off as u32)] {
1595            out.extend_from_slice(&ty.to_be_bytes());
1596            out.extend_from_slice(&off.to_be_bytes());
1597        }
1598        out.push(0u8); // placeholder body for comp0
1599        debug_assert_eq!(out.len(), stats_off);
1600        out.extend_from_slice(&stats);
1601        out.extend_from_slice(&0u32.to_be_bytes()); // trailing CRC
1602        out
1603    }
1604
1605    #[test]
1606    fn full_walk_nb_decodes_null_pending_and_transient() {
1607        let bytes = synthetic_full_nb(0, None, false);
1608        let md = parse_repair_metadata(&bytes, Some(&nb_gates())).expect("decode");
1609        assert_eq!(md.repaired_at, 0);
1610        assert!(md.repaired_at_decoded);
1611        assert_eq!(md.pending_repair, RepairField::Decoded(None));
1612        assert_eq!(md.is_transient, RepairField::Decoded(false));
1613    }
1614
1615    #[test]
1616    fn full_walk_nb_decodes_pending_uuid_and_transient_true() {
1617        let uuid = [
1618            0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
1619            0xFF, 0x00,
1620        ];
1621        let bytes = synthetic_full_nb(1_700_000_000_000, Some(uuid), true);
1622        let md = parse_repair_metadata(&bytes, Some(&nb_gates())).expect("decode");
1623        assert_eq!(md.repaired_at, 1_700_000_000_000);
1624        assert_eq!(md.pending_repair, RepairField::Decoded(Some(uuid)));
1625        assert_eq!(md.is_transient, RepairField::Decoded(true));
1626    }
1627
1628    #[test]
1629    fn full_walk_da_decodes_pending_uuid_and_transient() {
1630        let uuid = [0xABu8; 16];
1631        let bytes = synthetic_full_da(42, Some(uuid), true);
1632        let md = parse_repair_metadata(&bytes, Some(&da_gates())).expect("decode");
1633        assert_eq!(md.repaired_at, 42);
1634        assert_eq!(md.pending_repair, RepairField::Decoded(Some(uuid)));
1635        assert_eq!(md.is_transient, RepairField::Decoded(true));
1636    }
1637
1638    #[test]
1639    fn full_walk_da_decodes_null_pending_non_transient() {
1640        let bytes = synthetic_full_da(0, None, false);
1641        let md = parse_repair_metadata(&bytes, Some(&da_gates())).expect("decode");
1642        assert_eq!(md.pending_repair, RepairField::Decoded(None));
1643        assert_eq!(md.is_transient, RepairField::Decoded(false));
1644    }
1645
1646    #[test]
1647    fn full_walk_none_gates_reports_unparsed() {
1648        // Without gates the walk past repairedAt is not attempted, so the two
1649        // later fields are reported honestly as Unparsed (not fabricated).
1650        let bytes = synthetic_full_nb(7, Some([0x01u8; 16]), true);
1651        let md = parse_repair_metadata(&bytes, None).expect("decode repairedAt only");
1652        assert_eq!(md.repaired_at, 7);
1653        assert_eq!(md.pending_repair, RepairField::Unparsed);
1654        assert_eq!(md.is_transient, RepairField::Unparsed);
1655    }
1656
1657    #[test]
1658    fn full_walk_truncated_pending_fails_closed() {
1659        // Drop the trailing CRC + isTransient + part of the UUID so the
1660        // pendingRepair UUID read overruns the bounded STATS slice.
1661        let mut bytes = synthetic_full_nb(0, Some([0x07u8; 16]), true);
1662        bytes.truncate(bytes.len() - (4 + 1 + 8));
1663        assert!(
1664            parse_repair_metadata(&bytes, Some(&nb_gates())).is_err(),
1665            "a truncated pendingRepair UUID must fail closed, not default"
1666        );
1667    }
1668
1669    #[test]
1670    fn read_unsigned_vint_matches_encode_vuint() {
1671        use crate::parser::vint::encode_vuint;
1672        for v in [0u64, 1, 63, 127, 128, 255, 300, 16383, 16384, 1_000_000] {
1673            let enc = encode_vuint(v);
1674            let mut c = Cursor::new(&enc);
1675            assert_eq!(c.read_unsigned_vint().expect("decode vint"), v, "v={v}");
1676        }
1677    }
1678
1679    #[test]
1680    fn stats_extras_real_max_ldt_nb() {
1681        // Real seconds-since-epoch deletion time, well under 2^31.
1682        let bytes = synthetic_statistics_extras(false, 1_700_000_000, &[]);
1683        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1684        assert_eq!(extras.max_local_deletion_time, 1_700_000_000);
1685        assert!(extras.tombstone_drop_times.is_empty());
1686        // Default fixture: maxTimestamp=200 (NOT the min=100 placeholder). #1729.
1687        assert_eq!(extras.max_timestamp, Some(200));
1688        // Default fixture: maxTTL=0 (no expiring cells) → None (#1537).
1689        assert_eq!(extras.max_ttl, None);
1690    }
1691
1692    #[test]
1693    fn stats_extras_decodes_authoritative_max_ttl() {
1694        // Issue #1537: the STATS-extras walk must surface the authoritative maxTTL
1695        // (field 9) so the compaction TTL-expiry LDT floor can lower the output's
1696        // min-LDT baseline below a creation-time (`ldt - ttl`) tombstone. A positive
1697        // maxTTL is the true max; `0` (Cassandra `Cell.NO_TTL`) means "no expiring
1698        // cells" and must surface as `None`.
1699        let bytes =
1700            synthetic_statistics_extras_ts(false, i32::MAX as u32, &[], 100, 200, 10_000_000);
1701        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1702        assert_eq!(
1703            extras.max_ttl,
1704            Some(10_000_000),
1705            "authoritative maxTTL must be decoded from STATS field 9"
1706        );
1707
1708        // maxTTL == 0 → no expiring cells → None (fail-closed).
1709        let none_bytes = synthetic_statistics_extras_ts(false, i32::MAX as u32, &[], 100, 200, 0);
1710        let none_extras = parse_stats_extras(&none_bytes, Some(&nb_gates())).expect("decode");
1711        assert_eq!(
1712            none_extras.max_ttl, None,
1713            "maxTTL of 0 (no expiring cells) must surface as None"
1714        );
1715    }
1716
1717    #[test]
1718    fn stats_extras_decodes_authoritative_max_timestamp_not_min() {
1719        // Acceptance criterion #3: min < X < max, parsed max must equal the
1720        // true max (not the min placeholder the enhanced parser used to emit).
1721        let min_ts = 1_000_000i64;
1722        let max_ts = 5_000_000i64;
1723        let bytes = synthetic_statistics_extras_ts(false, i32::MAX as u32, &[], min_ts, max_ts, 0);
1724        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1725        assert_eq!(
1726            extras.max_timestamp,
1727            Some(max_ts),
1728            "parsed max_timestamp must be the true max, not the min"
1729        );
1730        assert_ne!(
1731            extras.max_timestamp,
1732            Some(min_ts),
1733            "max_timestamp must NOT equal the min placeholder"
1734        );
1735    }
1736
1737    #[test]
1738    fn stats_extras_max_timestamp_long_min_sentinel_is_none() {
1739        // Fail-closed (#1729): Cassandra seeds the max tracker with
1740        // Long.MIN_VALUE; an SSTable with no recorded write timestamp
1741        // serializes that sentinel → surfaced as None, never a bogus max.
1742        let bytes =
1743            synthetic_statistics_extras_ts(false, i32::MAX as u32, &[], i64::MAX, i64::MIN, 0);
1744        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1745        assert_eq!(extras.max_timestamp, None);
1746    }
1747
1748    #[test]
1749    fn stats_extras_default_max_timestamp_is_none() {
1750        // No STATS component → fail-closed None (no authoritative max).
1751        let extras = StatsExtras::default();
1752        assert_eq!(extras.max_timestamp, None);
1753    }
1754
1755    #[test]
1756    fn stats_extras_nb_sentinel_maps_to_i64_max() {
1757        // nb sentinel: i32::MAX → i64::MAX (NO_DELETION_TIME).
1758        let bytes = synthetic_statistics_extras(false, i32::MAX as u32, &[]);
1759        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1760        assert_eq!(extras.max_local_deletion_time, i64::MAX);
1761        // gates == None also defaults to legacy → same result.
1762        let extras_none = parse_stats_extras(&bytes, None).expect("decode");
1763        assert_eq!(extras_none.max_local_deletion_time, i64::MAX);
1764    }
1765
1766    #[test]
1767    fn stats_extras_modern_sentinel_maps_to_i64_max() {
1768        // modern sentinel: 0xFFFF_FFFF → i64::MAX (NO_DELETION_TIME).
1769        let bytes = synthetic_statistics_extras(true, u32::MAX, &[]);
1770        let extras = parse_stats_extras(&bytes, Some(&oa_gates())).expect("decode");
1771        assert_eq!(extras.max_local_deletion_time, i64::MAX);
1772    }
1773
1774    #[test]
1775    fn stats_extras_modern_real_max_ldt() {
1776        // A real deletion time above i32::MAX is representable as u32 on modern.
1777        let raw: u32 = 3_000_000_000; // > i32::MAX, < u32::MAX
1778        let bytes = synthetic_statistics_extras(true, raw, &[]);
1779        let extras = parse_stats_extras(&bytes, Some(&oa_gates())).expect("decode");
1780        assert_eq!(extras.max_local_deletion_time, raw as i64);
1781    }
1782
1783    #[test]
1784    fn stats_extras_histogram_nb_width() {
1785        let bins = [(1_700_000_000i64, 3u64), (1_700_000_100i64, 7u64)];
1786        let bytes = synthetic_statistics_extras(false, i32::MAX as u32, &bins);
1787        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1788        assert_eq!(
1789            extras.tombstone_drop_times,
1790            vec![(1_700_000_000, 3), (1_700_000_100, 7)]
1791        );
1792    }
1793
1794    #[test]
1795    fn stats_extras_histogram_modern_width() {
1796        let bins = [(1_700_000_000i64, 11u64), (1_700_000_500i64, 2u64)];
1797        let bytes = synthetic_statistics_extras(true, u32::MAX, &bins);
1798        let extras = parse_stats_extras(&bytes, Some(&oa_gates())).expect("decode");
1799        assert_eq!(
1800            extras.tombstone_drop_times,
1801            vec![(1_700_000_000, 11), (1_700_000_500, 2)]
1802        );
1803    }
1804
1805    #[test]
1806    fn stats_extras_empty_histogram() {
1807        let bytes = synthetic_statistics_extras(false, i32::MAX as u32, &[]);
1808        let extras = parse_stats_extras(&bytes, Some(&nb_gates())).expect("decode");
1809        assert!(extras.tombstone_drop_times.is_empty());
1810    }
1811
1812    #[test]
1813    fn stats_extras_missing_component_returns_default() {
1814        // TOC with no STATS entry → default (max = i64::MAX, empty histogram),
1815        // not an error.
1816        let mut out = Vec::new();
1817        out.extend_from_slice(&1u32.to_be_bytes()); // 1 component
1818        out.extend_from_slice(&0u32.to_be_bytes()); // marker
1819        out.extend_from_slice(&3u32.to_be_bytes()); // type HEADER
1820        out.extend_from_slice(&16u32.to_be_bytes()); // offset
1821        let extras = parse_stats_extras(&out, None).expect("default");
1822        assert_eq!(extras.max_local_deletion_time, i64::MAX);
1823        assert!(extras.tombstone_drop_times.is_empty());
1824    }
1825
1826    #[test]
1827    fn stats_extras_truncated_fails_closed() {
1828        // Two bins so the histogram region is large; truncate deep enough that
1829        // the cursor runs off the end while still reading the histogram (the
1830        // sstableLevel/repairedAt tail is not read by parse_stats_extras).
1831        let mut bytes =
1832            synthetic_statistics_extras(false, 1_700_000_000, &[(1i64, 1u64), (2i64, 2u64)]);
1833        // Drop the trailing CRC + sstableLevel + repairedAt + one full bin
1834        // (16 bytes legacy) + part of the next, so a histogram read overruns.
1835        bytes.truncate(bytes.len() - (4 + 4 + 8 + 16 + 4));
1836        assert!(
1837            parse_stats_extras(&bytes, Some(&nb_gates())).is_err(),
1838            "truncated STATS body must fail closed"
1839        );
1840    }
1841
1842    #[test]
1843    fn tombstone_histogram_huge_size_fails_closed_without_allocating() {
1844        // A corrupt header advertising a massive bin count must fail closed via
1845        // the remaining-bytes precheck rather than attempting a huge (or
1846        // aborting) `Vec::with_capacity` (roborev High finding, #1073).
1847        for modern in [false, true] {
1848            let mut buf = Vec::new();
1849            buf.extend_from_slice(&100i32.to_be_bytes()); // maxBinSize
1850            buf.extend_from_slice(&i32::MAX.to_be_bytes()); // size = 2.1B bins, no body
1851            let mut c = Cursor::new(&buf);
1852            assert!(
1853                read_tombstone_histogram(&mut c, modern).is_err(),
1854                "huge histogram size (modern={modern}) must fail closed, not allocate"
1855            );
1856        }
1857    }
1858
1859    #[test]
1860    fn decodes_repaired_at_legacy_histogram() {
1861        let bytes = synthetic_statistics(false, 0);
1862        let md = parse_repair_metadata(&bytes, None).expect("decode");
1863        assert_eq!(md.repaired_at, 0);
1864        assert!(md.repaired_at_decoded);
1865        // pending_repair / is_transient are not walked → reported as Unparsed,
1866        // NOT as a fabricated null / false.
1867        assert_eq!(md.pending_repair, RepairField::Unparsed);
1868        assert_eq!(md.is_transient, RepairField::Unparsed);
1869        assert!(!md.pending_repair.is_decoded());
1870        assert!(!md.is_transient.is_decoded());
1871    }
1872
1873    #[test]
1874    fn decodes_nonzero_repaired_at() {
1875        let bytes = synthetic_statistics(false, 1_700_000_000_000);
1876        let md = parse_repair_metadata(&bytes, None).expect("decode");
1877        assert_eq!(md.repaired_at, 1_700_000_000_000);
1878        assert!(md.repaired_at_decoded);
1879    }
1880
1881    #[test]
1882    fn malformed_toc_fails_closed() {
1883        // A corrupt/truncated TOC must NOT be silently reported as the
1884        // unrepaired default (repaired_at=0); it must fail closed.
1885        // (a) non-empty file shorter than the 8-byte TOC header.
1886        assert!(parse_repair_metadata(&[0u8, 0, 0], None).is_err());
1887        // (b) component count of zero.
1888        let mut zero = Vec::new();
1889        zero.extend_from_slice(&0u32.to_be_bytes());
1890        zero.extend_from_slice(&0u32.to_be_bytes());
1891        assert!(parse_repair_metadata(&zero, None).is_err());
1892        // (c) absurd component count.
1893        let mut huge = Vec::new();
1894        huge.extend_from_slice(&101u32.to_be_bytes());
1895        huge.extend_from_slice(&0u32.to_be_bytes());
1896        assert!(parse_repair_metadata(&huge, None).is_err());
1897        // (d) well-formed count but the TOC body is truncated.
1898        let mut truncated = Vec::new();
1899        truncated.extend_from_slice(&4u32.to_be_bytes()); // claims 4 entries
1900        truncated.extend_from_slice(&0u32.to_be_bytes()); // marker
1901        truncated.extend_from_slice(&2u32.to_be_bytes()); // only a partial first entry
1902        assert!(parse_repair_metadata(&truncated, None).is_err());
1903    }
1904
1905    #[test]
1906    fn missing_stats_component_reports_unrepaired_default() {
1907        // TOC with no STATS entry (only HEADER) → default, not an error.
1908        let mut out = Vec::new();
1909        out.extend_from_slice(&1u32.to_be_bytes()); // 1 component
1910        out.extend_from_slice(&0u32.to_be_bytes()); // marker
1911        out.extend_from_slice(&3u32.to_be_bytes()); // type HEADER
1912        out.extend_from_slice(&16u32.to_be_bytes()); // offset (irrelevant)
1913        let md = parse_repair_metadata(&out, None).expect("default");
1914        assert_eq!(md, RepairMetadata::unrepaired_default());
1915        assert!(!md.repaired_at_decoded);
1916    }
1917
1918    #[test]
1919    fn truncated_stats_fails_closed() {
1920        let mut bytes = synthetic_statistics(false, 0);
1921        // Truncate inside the STATS body so the forward walk runs off the end.
1922        bytes.truncate(bytes.len() - 4);
1923        let err = parse_repair_metadata(&bytes, None);
1924        assert!(
1925            err.is_err(),
1926            "truncated STATS component must fail closed, got {err:?}"
1927        );
1928    }
1929
1930    /// A STATS body whose internal length field overruns the component's end
1931    /// bound (but still fits within the rest of the file) must fail closed,
1932    /// proving the cursor is bounded by the STATS component, not by the file.
1933    #[test]
1934    fn stats_overrunning_component_bound_fails_closed() {
1935        // Build a buffer where STATS is NOT the last component, so there is a
1936        // following component AND a trailing CRC the decoder must never read.
1937        let mut stats = Vec::new();
1938        // Empty estimatedPartitionSize.
1939        stats.extend_from_slice(&0i32.to_be_bytes());
1940        // A SECOND EstimatedHistogram whose bucket count overruns the bound.
1941        // We will set this count after we know how many bytes remain.
1942        let bad_count_pos = stats.len();
1943        stats.extend_from_slice(&0i32.to_be_bytes()); // placeholder
1944                                                      // No more STATS bytes: the component ends right after this field.
1945
1946        // Layout: [count][marker][3 entries][stats][NEXT component bytes][crc].
1947        // STATS is entry index 1; a HEADER component follows it in the file so
1948        // there ARE bytes after STATS that the bad count would spill into.
1949        let toc_len = 4 + 4 + 3 * 8;
1950        let stats_off = toc_len;
1951        let next_off = stats_off + stats.len(); // HEADER directly after STATS
1952        let header_bytes = [0xAAu8; 64]; // plenty of bytes after STATS
1953
1954        // Now make the second histogram's bucket count large enough that
1955        // 16*count would run past the STATS end but still inside the file.
1956        let bad_count: i32 = 8; // 8 * 16 = 128 bytes, far past the 0-byte remainder
1957        stats[bad_count_pos..bad_count_pos + 4].copy_from_slice(&bad_count.to_be_bytes());
1958
1959        let mut out = Vec::new();
1960        out.extend_from_slice(&3u32.to_be_bytes());
1961        out.extend_from_slice(&0u32.to_be_bytes());
1962        for (ty, off) in [
1963            (3u32, next_off as u32), // HEADER (follows STATS)
1964            (2u32, stats_off as u32),
1965            (0u32, (next_off + header_bytes.len()) as u32),
1966        ] {
1967            out.extend_from_slice(&ty.to_be_bytes());
1968            out.extend_from_slice(&off.to_be_bytes());
1969        }
1970        debug_assert_eq!(out.len(), stats_off);
1971        out.extend_from_slice(&stats);
1972        out.extend_from_slice(&header_bytes); // next component
1973        out.extend_from_slice(&0u32.to_be_bytes()); // trailing CRC
1974
1975        let err = parse_repair_metadata(&out, None);
1976        assert!(
1977            err.is_err(),
1978            "a STATS body that overruns its component bound must fail closed \
1979             (not spill into the next component / CRC), got {err:?}"
1980        );
1981    }
1982
1983    /// The component end bound is derived from the NEXT TOC offset (when STATS
1984    /// is not last), excluding the trailing CRC when STATS IS last.
1985    #[test]
1986    fn component_bounds_derive_end_from_next_offset() {
1987        // STATS-last synthetic: end == file_len - 4 (the CRC).
1988        let bytes = synthetic_statistics(false, 0);
1989        let bounds = stats_component_bounds(&bytes)
1990            .expect("no error")
1991            .expect("bounds present");
1992        assert_eq!(
1993            bounds.end,
1994            bytes.len() - METADATA_COMPONENT_CRC_LEN,
1995            "STATS-last end must exclude the trailing CRC"
1996        );
1997        assert!(bounds.start < bounds.end);
1998    }
1999
2000    /// When STATS is NOT the last component, its end must be the next component's
2001    /// TOC offset MINUS the 4-byte per-component CRC32 Cassandra writes between
2002    /// each component body and the next component's offset — never the raw next
2003    /// offset (which would let the decoder read 4 CRC bytes as metadata).
2004    #[test]
2005    fn nonlast_stats_end_excludes_component_crc() {
2006        // Realistic layout: [count][marker][2 entries][STATS body][STATS crc][HEADER body][HEADER crc]
2007        let mut stats = Vec::new();
2008        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedPartitionSize (empty)
2009        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedCellPerPartitionCount (empty)
2010        stats.extend_from_slice(&7i64.to_be_bytes()); // a few more bytes of body
2011
2012        let toc_len = 4 + 4 + 2 * 8;
2013        let stats_off = toc_len;
2014        // HEADER begins AFTER the STATS body + its 4-byte CRC.
2015        let header_off = stats_off + stats.len() + METADATA_COMPONENT_CRC_LEN;
2016        let header_body = [0xABu8; 8];
2017
2018        let mut out = Vec::new();
2019        out.extend_from_slice(&2u32.to_be_bytes()); // 2 components
2020        out.extend_from_slice(&0u32.to_be_bytes()); // marker
2021        for (ty, off) in [(2u32, stats_off as u32), (3u32, header_off as u32)] {
2022            out.extend_from_slice(&ty.to_be_bytes());
2023            out.extend_from_slice(&off.to_be_bytes());
2024        }
2025        debug_assert_eq!(out.len(), stats_off);
2026        out.extend_from_slice(&stats);
2027        let stats_crc = crc32_ieee(&out[stats_off..stats_off + stats.len()]);
2028        out.extend_from_slice(&stats_crc.to_be_bytes()); // per-component CRC after STATS
2029        debug_assert_eq!(out.len(), header_off);
2030        out.extend_from_slice(&header_body);
2031        out.extend_from_slice(&0u32.to_be_bytes()); // HEADER's trailing CRC
2032
2033        let bounds = stats_component_bounds(&out)
2034            .expect("no error")
2035            .expect("bounds present");
2036        assert_eq!(
2037            bounds.end,
2038            header_off - METADATA_COMPONENT_CRC_LEN,
2039            "non-last STATS end must exclude the inter-component CRC"
2040        );
2041        assert_eq!(
2042            bounds.end,
2043            stats_off + stats.len(),
2044            "non-last STATS end must equal the true STATS body end"
2045        );
2046    }
2047
2048    /// Minimal CRC32 (IEEE) for the regression test's synthetic CRC bytes.
2049    fn crc32_ieee(data: &[u8]) -> u32 {
2050        let mut crc: u32 = 0xFFFF_FFFF;
2051        for &byte in data {
2052            crc ^= byte as u32;
2053            for _ in 0..8 {
2054                let mask = (crc & 1).wrapping_neg();
2055                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
2056            }
2057        }
2058        !crc
2059    }
2060
2061    // ---- read_table_counts (issue #944) ----------------------------------
2062
2063    /// Build an `nb`-layout STATS body with a POPULATED estimatedPartitionSize
2064    /// histogram (so `partition_count` is non-zero) and a known `totalRows`.
2065    /// `partition_buckets` are `(offset, count)` pairs; Σ counts = partition_count.
2066    fn synthetic_counts_nb(partition_buckets: &[(i64, i64)], total_rows: i64) -> Vec<u8> {
2067        let mut stats = Vec::new();
2068        // estimatedPartitionSize: i32 bucketCount, then (i64 offset, i64 count)*.
2069        stats.extend_from_slice(&(partition_buckets.len() as i32).to_be_bytes());
2070        for (off, cnt) in partition_buckets {
2071            stats.extend_from_slice(&off.to_be_bytes());
2072            stats.extend_from_slice(&cnt.to_be_bytes());
2073        }
2074        stats.extend_from_slice(&0i32.to_be_bytes()); // estimatedCellPerPartitionCount (empty)
2075        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogUpperBound segmentId
2076        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogUpperBound position
2077        stats.extend_from_slice(&100i64.to_be_bytes()); // minTimestamp
2078        stats.extend_from_slice(&200i64.to_be_bytes()); // maxTimestamp
2079        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // minLocalDeletionTime
2080        stats.extend_from_slice(&i32::MAX.to_be_bytes()); // maxLocalDeletionTime
2081        stats.extend_from_slice(&0i32.to_be_bytes()); // minTTL
2082        stats.extend_from_slice(&0i32.to_be_bytes()); // maxTTL
2083        stats.extend_from_slice(&(-1.0f64).to_be_bytes()); // compressionRatio
2084        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram maxBinSize
2085        stats.extend_from_slice(&0i32.to_be_bytes()); // TombstoneHistogram size
2086        stats.extend_from_slice(&0i32.to_be_bytes()); // sstableLevel
2087        stats.extend_from_slice(&0i64.to_be_bytes()); // repairedAt
2088        stats.extend_from_slice(&0i32.to_be_bytes()); // legacy min clustering list (empty)
2089        stats.extend_from_slice(&0i32.to_be_bytes()); // legacy max clustering list (empty)
2090        stats.push(0x00); // hasLegacyCounterShards
2091        stats.extend_from_slice(&7u64.to_be_bytes()); // totalColumnsSet (arbitrary)
2092        stats.extend_from_slice(&total_rows.to_be_bytes()); // totalRows
2093        stats.extend_from_slice(&(-1i64).to_be_bytes()); // commitLogLowerBound segmentId
2094        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogLowerBound position
2095        stats.extend_from_slice(&0i32.to_be_bytes()); // commitLogIntervals size = 0
2096        stats.push(0x00); // pendingRepair absent
2097        stats.push(0x00); // isTransient
2098        wrap_stats_last(stats)
2099    }
2100
2101    #[test]
2102    fn read_table_counts_sums_partition_histogram_and_total_rows() {
2103        // 3 + 2 + 5 = 10 partitions; 2000 total rows (wide table shape).
2104        let buf = synthetic_counts_nb(&[(0, 3), (16, 2), (64, 5)], 2000);
2105        let counts = read_table_counts(&buf, Some(&nb_gates())).expect("decode");
2106        assert_eq!(counts.partition_count, 10, "Σ histogram bucket counts");
2107        assert_eq!(counts.total_rows, Some(2000), "totalRows from field 12");
2108    }
2109
2110    #[test]
2111    fn read_table_counts_partition_count_without_gates_total_rows_none() {
2112        // Without gates, partition_count is still decoded (self-describing leading
2113        // histogram), but the gated walk to totalRows is not attempted.
2114        let buf = synthetic_counts_nb(&[(0, 4), (8, 6)], 999);
2115        let counts = read_table_counts(&buf, None).expect("decode");
2116        assert_eq!(counts.partition_count, 10);
2117        assert_eq!(
2118            counts.total_rows, None,
2119            "no gates → totalRows honestly absent, never guessed"
2120        );
2121    }
2122
2123    /// Issue #2148 blocker (roborev + rust-reviewer): a corrupt STATS byte-range
2124    /// must NOT discard an already-resolved HEADER offset. The pre-#2148 header
2125    /// walk (`parse_statistics_toc_for_header_offset`) was independent of any
2126    /// STATS-range check, so a Statistics.db with a VALID HEADER entry but an
2127    /// INVALID STATS range still decoded EncodingStats authoritatively from the
2128    /// HEADER. The single-pass walk must preserve that: the STATS channel fails
2129    /// closed while `header_offset()` still returns the resolved HEADER — otherwise
2130    /// EncodingStats silently falls back to the marker search (a different result).
2131    #[test]
2132    fn invalid_stats_range_preserves_resolved_header_offset() {
2133        // 2-component TOC: HEADER (type 3) at a VALID body offset, STATS (type 2)
2134        // at an offset PAST EOF (an invalid derived range → fail-closed STATS).
2135        let toc_len = 4 + 4 + 2 * 8; // count + marker + 2 entries = 24
2136        let header_off = toc_len; // HEADER body starts right after the TOC
2137        let stats_off_bad = 10_000usize; // far past the buffer end → invalid range
2138
2139        let mut out = Vec::new();
2140        out.extend_from_slice(&2u32.to_be_bytes()); // 2 components
2141        out.extend_from_slice(&0u32.to_be_bytes()); // marker
2142                                                    // HEADER first (first-wins) with a valid offset, then the bad STATS entry.
2143        for (ty, off) in [
2144            (METADATA_TYPE_HEADER, header_off as u32),
2145            (METADATA_TYPE_STATS, stats_off_bad as u32),
2146        ] {
2147            out.extend_from_slice(&ty.to_be_bytes());
2148            out.extend_from_slice(&off.to_be_bytes());
2149        }
2150        debug_assert_eq!(out.len(), header_off);
2151        out.extend_from_slice(&[0xABu8; 8]); // HEADER body
2152        out.extend_from_slice(&0u32.to_be_bytes()); // trailing CRC
2153
2154        let toc = parse_statistics_toc(&out);
2155        assert_eq!(
2156            toc.header_offset(),
2157            Some(header_off),
2158            "a corrupt STATS range must NOT nuke the already-resolved HEADER offset \
2159             (issue #2148): EncodingStats must still decode from the HEADER, not the \
2160             marker fallback"
2161        );
2162        assert!(
2163            toc.stats_bounds().is_err(),
2164            "the invalid STATS range must STILL fail closed on its own channel"
2165        );
2166    }
2167
2168    #[test]
2169    fn read_table_counts_no_stats_component_is_all_zero() {
2170        // A TOC with only a non-STATS component → no counts to decode.
2171        let mut out = Vec::new();
2172        out.extend_from_slice(&1u32.to_be_bytes()); // 1 component
2173        out.extend_from_slice(&0u32.to_be_bytes()); // checksum
2174        out.extend_from_slice(&0u32.to_be_bytes()); // type 0 (VALIDATION), not STATS
2175        out.extend_from_slice(&16u32.to_be_bytes()); // offset
2176        out.push(0u8); // body
2177        out.extend_from_slice(&0u32.to_be_bytes()); // trailing CRC
2178        let counts = read_table_counts(&out, Some(&nb_gates())).expect("decode");
2179        assert_eq!(counts.partition_count, 0);
2180        assert_eq!(counts.total_rows, None);
2181    }
2182}