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