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