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