lsm_tree/verify.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5use crate::path::{Path, PathBuf};
6use crate::rate_limiter::RateLimiter;
7use crate::{checksum::Checksum, coding::Decode, io, table::TableId, table::block::Header};
8#[cfg(not(feature = "std"))]
9use alloc::{boxed::Box, string::String, vec::Vec};
10
11/// Describes a single integrity error found during verification.
12///
13/// Full-file integrity (hashing whole files by path) uses `std::fs` directly and
14/// is gated to `std`; the `no_std` verify path is block-level over the injected
15/// [`Fs`](crate::fs::Fs) backend (see [`verify_block_checksums`]).
16#[cfg(feature = "std")]
17#[derive(Debug)]
18#[non_exhaustive]
19pub enum IntegrityError {
20 /// Full-file checksum mismatch for an SST table.
21 SstFileCorrupted {
22 /// Table ID
23 table_id: TableId,
24 /// Path to the corrupted file
25 path: PathBuf,
26 /// Checksum stored in the manifest
27 expected: Checksum,
28 /// Checksum computed from disk
29 got: Checksum,
30 },
31
32 /// Full-file checksum mismatch for a blob file.
33 BlobFileCorrupted {
34 /// Blob file ID
35 blob_file_id: u64,
36 /// Path to the corrupted file
37 path: PathBuf,
38 /// Checksum stored in the manifest
39 expected: Checksum,
40 /// Checksum computed from disk
41 got: Checksum,
42 },
43
44 /// I/O error while reading a file during verification.
45 IoError {
46 /// Path to the file that could not be read
47 path: PathBuf,
48 /// The underlying I/O error
49 error: io::Error,
50 },
51}
52
53#[cfg(feature = "std")]
54impl core::fmt::Display for IntegrityError {
55 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56 match self {
57 Self::SstFileCorrupted {
58 table_id,
59 path,
60 expected,
61 got,
62 } => write!(
63 f,
64 "SST table {table_id} corrupted at {}: expected {expected}, got {got}",
65 path.display()
66 ),
67 Self::BlobFileCorrupted {
68 blob_file_id,
69 path,
70 expected,
71 got,
72 } => write!(
73 f,
74 "blob file {blob_file_id} corrupted at {}: expected {expected}, got {got}",
75 path.display()
76 ),
77 Self::IoError { path, error } => {
78 write!(f, "I/O error reading {}: {}", path.display(), error)
79 }
80 }
81 }
82}
83
84#[cfg(feature = "std")]
85impl core::error::Error for IntegrityError {
86 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
87 match self {
88 Self::IoError { error, .. } => Some(error),
89 _ => None,
90 }
91 }
92}
93
94/// Result of an integrity verification scan.
95///
96/// The `sst_files_checked` and `blob_files_checked` counters reflect
97/// the number of files *attempted* — including those that produced I/O
98/// errors. This lets callers reconcile the total against the manifest
99/// even when some files were unreadable.
100#[cfg(feature = "std")]
101#[derive(Debug)]
102#[non_exhaustive]
103pub struct IntegrityReport {
104 /// Number of SST table files checked (includes I/O errors).
105 pub sst_files_checked: usize,
106
107 /// Number of blob files checked (includes I/O errors).
108 pub blob_files_checked: usize,
109
110 /// Integrity errors found during verification.
111 pub errors: Vec<IntegrityError>,
112}
113
114#[cfg(feature = "std")]
115impl IntegrityReport {
116 /// Returns `true` if no errors were found.
117 #[must_use]
118 pub fn is_ok(&self) -> bool {
119 self.errors.is_empty()
120 }
121
122 /// Total number of files checked (SST + blob).
123 #[must_use]
124 pub fn files_checked(&self) -> usize {
125 self.sst_files_checked + self.blob_files_checked
126 }
127}
128
129/// Computes a streaming XXH3 128-bit checksum over `[start, end)` of a file,
130/// without loading it entirely into memory. Pass `start = 0` for a whole file.
131///
132/// A tight-space RESTRICTED table's `[0, punch_offset)` prefix — and a
133/// relocated blob file's prefix below its live-data frontier — is hole-punched
134/// (reads as zeros) once a superseding output owns that data, so the manifest
135/// digest covers only the live suffix; verification must digest from the same
136/// `start`.
137#[cfg(feature = "std")]
138pub(crate) fn stream_checksum_from(
139 path: &std::path::Path,
140 start: u64,
141) -> std::io::Result<Checksum> {
142 use std::io::{Read, Seek, SeekFrom};
143
144 let mut reader = std::fs::File::open(path)?;
145 if start != 0 {
146 reader.seek(SeekFrom::Start(start))?;
147 }
148 let mut hasher = xxhash_rust::xxh3::Xxh3Default::new();
149 let mut buf = vec![0u8; 64 * 1024];
150
151 loop {
152 let n = match reader.read(&mut buf) {
153 Ok(n) => n,
154 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
155 Err(e) => return Err(e),
156 };
157 if n == 0 {
158 break;
159 }
160 // Safety: Read::read guarantees n <= buf.len(), so get(..n) always
161 // returns Some. We use .get() instead of direct indexing to satisfy
162 // the crate-wide #[deny(clippy::indexing_slicing)] lint.
163 if let Some(chunk) = buf.get(..n) {
164 hasher.update(chunk);
165 }
166 }
167
168 Ok(Checksum::from_raw(hasher.digest128()))
169}
170
171/// Verifies full-file checksums for all SST and blob files in the given tree.
172///
173/// Each file's content is read from disk and hashed with XXHash-3 128-bit,
174/// then compared against the checksum stored in the version manifest.
175///
176/// This detects silent bit-rot, partial writes, and other on-disk corruption.
177///
178/// Per-file errors (e.g., unreadable files, checksum mismatches) are collected
179/// into [`IntegrityReport::errors`] — the scan always runs to completion.
180#[cfg(feature = "std")]
181#[must_use]
182pub fn verify_integrity(tree: &impl crate::AbstractTree) -> IntegrityReport {
183 let version = tree.current_version();
184
185 let mut report = IntegrityReport {
186 sst_files_checked: 0,
187 blob_files_checked: 0,
188 errors: Vec::new(),
189 };
190
191 // Verify all SST table files
192 for table in version.iter_tables() {
193 let path = &*table.path;
194 let expected = table.checksum();
195
196 // A tight-space RESTRICTED view digests only its live suffix (the
197 // punched prefix reads as zeros and is not part of its identity).
198 let start = match table.restrict_lower_bound() {
199 Some(bound) => match table.punch_offset_for(bound) {
200 Ok(offset) => offset,
201 // An ENVIRONMENTAL index-read failure (a retryable EINTR /
202 // EAGAIN, but equally a refused mount, an exhausted allocator,
203 // a missing key) says nothing about the bytes: falling back to
204 // `0` would digest the hole-punched prefix and report a healthy
205 // restricted table as corrupted once the condition clears.
206 // Mirror `scan_one_table`, which routes the same failure to an
207 // unreadable/IoError classification and skips the comparison.
208 Err(e) if e.is_environmental() => {
209 report.errors.push(IntegrityError::IoError {
210 path: (*table.path).clone(),
211 error: environmental_as_io(e),
212 });
213 report.sst_files_checked += 1;
214 continue;
215 }
216 // A failure on the DATA (a bad sector surfacing as `Other` /
217 // EIO) and a STRUCTURAL failure both fall back to `0` (fail
218 // closed): the whole-file digest then mismatches and the table
219 // is reported rather than silently passing.
220 Err(_) => 0,
221 },
222 None => 0,
223 };
224 match stream_checksum_from(path, start) {
225 Ok(got) if got != expected => {
226 report.errors.push(IntegrityError::SstFileCorrupted {
227 table_id: table.id(),
228 path: (*table.path).clone(),
229 expected,
230 got,
231 });
232 }
233 Ok(_) => {}
234 Err(e) => {
235 report.errors.push(IntegrityError::IoError {
236 path: (*table.path).clone(),
237 error: e.into(),
238 });
239 }
240 }
241
242 report.sst_files_checked += 1;
243 }
244
245 // Verify all blob files
246 for blob_file in version.blob_files.iter() {
247 let path = blob_file.path();
248 let expected = blob_file.checksum();
249
250 // A blob file whose consumed prefix was reclaimed in place records its
251 // digest over the LIVE suffix only: hashing the whole file would fold
252 // in the punched (zeroed) prefix and report a healthy file as corrupt.
253 // `0` for a whole, unreclaimed file.
254 match stream_checksum_from(path, blob_file.live_data_start()) {
255 Ok(got) if got != expected => {
256 report.errors.push(IntegrityError::BlobFileCorrupted {
257 blob_file_id: blob_file.id(),
258 path: path.to_path_buf(),
259 expected,
260 got,
261 });
262 }
263 Ok(_) => {}
264 Err(e) => {
265 report.errors.push(IntegrityError::IoError {
266 path: path.to_path_buf(),
267 error: e.into(),
268 });
269 }
270 }
271
272 report.blob_files_checked += 1;
273 }
274
275 report
276}
277
278// ── Block-level scrub ─────────────────────────────────────────────────────
279// `verify_integrity` above hashes each SST as one opaque byte stream and
280// compares the digest to the per-file checksum stored in the manifest. That
281// catches whole-file corruption but identifies the bad region only at file
282// granularity. The functions below walk every block inside every SST and
283// verify per-block XXH3 against the value embedded in each block's own
284// header, so a corrupt block can be reported with its exact `(file, offset)`
285// without re-running the manifest-level scan.
286
287/// Per-block verification error.
288#[derive(Debug)]
289#[non_exhaustive]
290pub enum BlockVerifyError {
291 /// SST file could not be opened or its trailer parsed.
292 SstFileUnreadable {
293 /// Table ID.
294 table_id: TableId,
295 /// Path to the SST file.
296 path: PathBuf,
297 /// Underlying I/O / format error.
298 error: io::Error,
299 },
300
301 /// A block header at the given offset failed to parse — either
302 /// XXH3 mismatch on the header itself, or invalid magic bytes /
303 /// length fields that point at on-disk corruption.
304 HeaderCorrupted {
305 /// Table ID.
306 table_id: TableId,
307 /// Path to the SST file.
308 path: PathBuf,
309 /// File offset where the corrupt header was read from.
310 offset: u64,
311 /// Short description of the failure surfaced by header decoding.
312 reason: String,
313 },
314
315 /// A block's data XXH3 did not match the value stored in its header.
316 /// Indicates bit-rot or torn write on the block payload.
317 DataCorrupted {
318 /// Table ID.
319 table_id: TableId,
320 /// Path to the SST file.
321 path: PathBuf,
322 /// File offset where the block header sits (the data follows it).
323 offset: u64,
324 /// Length of the on-disk data segment, in bytes.
325 data_length: u32,
326 /// Checksum stored in the block header.
327 expected: Checksum,
328 /// Checksum computed from the on-disk bytes.
329 got: Checksum,
330 },
331
332 /// The block header was successfully decoded (its own XXH3
333 /// matched) but the subsequent fixed-length read of the data
334 /// segment failed at the filesystem layer — truncated file,
335 /// unexpected EOF, transient I/O error. Distinct from
336 /// `HeaderCorrupted` because the header itself was clean: the
337 /// failure is on the bytes that should follow it.
338 DataReadError {
339 /// Table ID.
340 table_id: TableId,
341 /// Path to the SST file.
342 path: PathBuf,
343 /// File offset where the (clean) header sits; the read for
344 /// its data segment started at `offset + Header::header_len(block_type)`.
345 offset: u64,
346 /// Length the (clean) header advertised for the data segment.
347 data_length: u32,
348 /// Underlying I/O error from the failed data-segment read.
349 /// Kept as `std::io::Error` (matching `SstFileUnreadable`) so
350 /// `ErrorKind` / OS code stay available to callers and so
351 /// `Error::source()` produces a coherent chain.
352 error: io::Error,
353 },
354
355 /// A block's Page-ECC parity trailer did not match parity freshly
356 /// computed over its (checksum-clean) payload. The payload itself is
357 /// intact — but the block's ECC is dead: a later payload fault could no
358 /// longer be recovered from this trailer. Reported only when the payload
359 /// checksum matched (a corrupt payload legitimately mismatches the
360 /// original trailer and is already reported as `DataCorrupted`).
361 EccParityMismatch {
362 /// Table ID.
363 table_id: TableId,
364 /// Path to the SST file.
365 path: PathBuf,
366 /// File offset where the block header sits.
367 offset: u64,
368 /// Length of the on-disk data segment, in bytes.
369 data_length: u32,
370 },
371
372 /// SFA TOC-level corruption: a named section's length / position
373 /// fields are inconsistent (overflow on addition), or seeking to
374 /// its declared start offset fails before any block is read.
375 /// Distinct from `HeaderCorrupted` (which is per-block) so
376 /// callers can tell "the section catalogue itself is bad" apart
377 /// from "block N inside an otherwise-walkable section is bad" —
378 /// e.g. a `TocCorrupted` makes the whole section unreachable,
379 /// while a `HeaderCorrupted` only stops that section's walk.
380 TocCorrupted {
381 /// Table ID.
382 table_id: TableId,
383 /// Path to the SST file.
384 path: PathBuf,
385 /// Section name from the TOC entry (e.g. `b"data"`,
386 /// `b"tli"`). Stored verbatim, not lossy-decoded, because
387 /// SFA section names are byte strings.
388 section_name: Vec<u8>,
389 /// File offset where the section *would* start per the TOC
390 /// entry. Useful for forensics even when the start is
391 /// unreachable.
392 section_offset: u64,
393 /// Short description of the failure (overflow on
394 /// start+length, seek error, etc.).
395 reason: String,
396 },
397}
398
399impl core::fmt::Display for BlockVerifyError {
400 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
401 match self {
402 Self::SstFileUnreadable {
403 table_id,
404 path,
405 error,
406 } => write!(
407 f,
408 "SST table {table_id} at {} could not be opened/parsed: {error}",
409 path.display(),
410 ),
411 Self::HeaderCorrupted {
412 table_id,
413 path,
414 offset,
415 reason,
416 } => write!(
417 f,
418 "SST table {table_id} at {}: block header at offset {offset} is corrupt ({reason})",
419 path.display(),
420 ),
421 Self::DataCorrupted {
422 table_id,
423 path,
424 offset,
425 data_length,
426 expected,
427 got,
428 } => write!(
429 f,
430 "SST table {table_id} at {}: block at offset {offset} ({data_length} bytes) data \
431 checksum mismatch, expected {expected}, got {got}",
432 path.display(),
433 ),
434 Self::DataReadError {
435 table_id,
436 path,
437 offset,
438 data_length,
439 error,
440 } => write!(
441 f,
442 "SST table {table_id} at {}: failed to read {data_length}-byte data segment for \
443 block at offset {offset}: {error}",
444 path.display(),
445 ),
446 Self::EccParityMismatch {
447 table_id,
448 path,
449 offset,
450 data_length,
451 } => write!(
452 f,
453 "SST table {table_id} at {}: block at offset {offset} ({data_length} bytes) has a \
454 clean payload but its ECC parity trailer does not match freshly computed parity \
455 (dead ECC — recompact or heal in place)",
456 path.display(),
457 ),
458 Self::TocCorrupted {
459 table_id,
460 path,
461 section_name,
462 section_offset,
463 reason,
464 } => write!(
465 f,
466 "SST table {table_id} at {}: TOC section {:?} at offset {section_offset} is \
467 unreachable ({reason})",
468 path.display(),
469 String::from_utf8_lossy(section_name),
470 ),
471 }
472 }
473}
474
475impl core::error::Error for BlockVerifyError {
476 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
477 match self {
478 Self::SstFileUnreadable { error, .. } | Self::DataReadError { error, .. } => {
479 Some(error)
480 }
481 _ => None,
482 }
483 }
484}
485
486/// A non-fatal finding from a scrub run: the data is intact, but something
487/// about a table could not be fully checked.
488///
489/// Warnings do not fail [`BlockVerifyReport::is_ok`], so any consumer that
490/// renders a verdict (a CLI, an operator report) MUST surface them alongside
491/// it: a bare "OK" over a non-empty warning list misreads "nothing broken
492/// among what was checkable" as "everything verified". The skipped surface
493/// each variant names (an unverifiable parity trailer, an unwalkable ECC
494/// section) is exactly where silent rot would otherwise hide.
495#[derive(Debug)]
496#[non_exhaustive]
497pub enum BlockVerifyWarning {
498 /// The table's `descriptor#page_ecc` decodes to an ECC scheme this build
499 /// cannot apply (an unimplemented scheme, page granularity, an unknown
500 /// kind, or a non-canonical descriptor). Block payloads still verify by
501 /// their own checksums, but the parity trailer length is not derivable
502 /// from a scheme, so the sequential block walk cannot size it and ECC
503 /// verification was skipped for this table. Recompaction re-stamps the
504 /// table with a supported scheme.
505 UnrecognizedEcc {
506 /// Table the warning applies to.
507 table_id: TableId,
508 /// On-disk path of the SST.
509 path: PathBuf,
510 },
511
512 /// The table carries a RECOGNIZED Page-ECC scheme, but this build was
513 /// compiled without the ECC codecs (the `page_ecc` feature), so its parity
514 /// trailers were consumed for walk alignment but could NOT be verified.
515 /// Block payloads still verify by their own checksums — parity-only rot is
516 /// what stays invisible on this build. Verify on a `page_ecc`-enabled
517 /// build, or recompact (on this build the rewrite is parity-less) to leave
518 /// only verifiable bytes.
519 ParityUnverifiable {
520 /// Table the warning applies to.
521 table_id: TableId,
522 /// On-disk path of the SST.
523 path: PathBuf,
524 },
525
526 /// The parity mismatches reported for this table look like a MIS-IDENTIFIED
527 /// ECC scheme rather than rot: the descriptor sizes every block correctly,
528 /// yet reproduces the parity of none of them. Rot is scattered — it does
529 /// not disagree with every trailer in every section at once.
530 ///
531 /// The payloads still verified against their own checksums, so the data is
532 /// readable either way and the table is graded on the mismatches alone.
533 /// This only says which explanation to investigate first: recompact the
534 /// table to re-stamp it under a scheme both meta mirrors agree on, rather
535 /// than hunting failing hardware.
536 ///
537 /// Raised only ALONGSIDE those mismatches, never on its own, so it cannot
538 /// turn an otherwise-clean report into a degraded one.
539 EccCodecSuspect {
540 /// Table the warning applies to.
541 table_id: TableId,
542 /// On-disk path of the SST.
543 path: PathBuf,
544 },
545
546 /// NEITHER meta mirror holds a readable ECC descriptor, and the blocks were
547 /// walked under a parity-less layout READ OFF THE FILE: they frame end to
548 /// end with no trailer, which a parity-bearing table cannot do.
549 ///
550 /// So this table verified completely — unlike [`Self::UnrecognizedEcc`],
551 /// nothing was skipped — but what is on disk is still malformed, and only a
552 /// rewrite re-stamps a canonical descriptor. Until then the table has no
553 /// recorded scheme to recover under, and the next reader must infer the
554 /// layout again.
555 EccDescriptorsUnreadable {
556 /// Table the warning applies to.
557 table_id: TableId,
558 /// On-disk path of the SST.
559 path: PathBuf,
560 },
561}
562
563/// Aggregated result of a per-block scrub run.
564#[derive(Debug, Default)]
565#[non_exhaustive]
566pub struct BlockVerifyReport {
567 /// Number of SST table files visited (one per scan).
568 pub sst_files_scanned: usize,
569 /// Total blocks successfully header-read across all SSTs. Includes
570 /// blocks where the data checksum subsequently failed.
571 pub blocks_scanned: usize,
572 /// Per-block errors collected during the scan. The scan always
573 /// runs to completion across all SSTs even if individual blocks
574 /// or whole files are corrupt.
575 pub errors: Vec<BlockVerifyError>,
576 /// Non-fatal findings: data verified, but ECC could not be checked for
577 /// some tables (unrecognized scheme — recompaction recommended). Distinct
578 /// from `errors`: warnings do NOT make [`Self::is_ok`] false.
579 pub warnings: Vec<BlockVerifyWarning>,
580 /// Set when the scan could NOT walk some SST-block sections — an unrecognized
581 /// ECC descriptor makes those blocks' parity-trailer length underivable, so
582 /// the walk skips them (including the DATA blocks) entirely. A report with no
583 /// errors but this flag set has verified LESS than the whole file, so it is
584 /// NOT a clean verdict: [`Self::is_ok`] returns `false`. (The data may still
585 /// be readable through the live point-read path, which frames blocks by
586 /// `data_length`; recompaction re-stamps the SST under a supported scheme.)
587 pub incomplete: bool,
588}
589
590impl BlockVerifyReport {
591 /// `true` only if every SST section was walked AND every block verified
592 /// clean. A parity-unverifiable WARNING (whose blocks were still walked and
593 /// payload-checksummed) does not make this false, but a real error
594 /// (`errors`) or an INCOMPLETE walk that skipped sections
595 /// ([`Self::incomplete`], e.g. an unrecognized ECC descriptor) does — a
596 /// skipped section was never verified, so reporting it clean would be a false
597 /// success.
598 #[must_use]
599 pub fn is_ok(&self) -> bool {
600 self.errors.is_empty() && !self.incomplete
601 }
602
603 /// `true` if the scrub produced any non-fatal warning.
604 #[must_use]
605 pub fn has_warnings(&self) -> bool {
606 !self.warnings.is_empty()
607 }
608}
609
610/// Options for the block-checksum scrubber
611/// ([`verify_block_checksums_with`] / [`AbstractTree::verify_checksum_with`](crate::AbstractTree::verify_checksum_with)).
612#[derive(Clone, Debug)]
613pub struct VerifyOptions {
614 /// Number of SSTs to scan concurrently. Clamped to `>= 1` and to the table
615 /// count. `1` (the default) scans sequentially in table order with no
616 /// thread spawn. Per-SST scans are independent (each opens its own file
617 /// through the table's `Fs` handle), so they parallelize cleanly.
618 pub parallelism: usize,
619
620 /// Minimum delay each worker waits after finishing one SST before taking
621 /// the next, capping I/O pressure on a production box during a scrub.
622 /// `None` (default) runs at full speed.
623 pub throttle: Option<core::time::Duration>,
624
625 /// The budget the scan reads under: every block and raw section is charged
626 /// its on-disk bytes before it is read, through this one limiter for all
627 /// [`parallelism`](Self::parallelism) workers. Shared, so a caller can
628 /// retune it live or draw it from the same budget as compaction. `None`
629 /// (default) reads at full speed. Only a `std` build waits on it.
630 ///
631 /// The scan reports how long each charged read took, so a limiter with a
632 /// [`LatencyBackoff`](crate::rate_limiter::LatencyBackoff) backs the scan
633 /// off while the device is slow to answer. The backoff lowers what the
634 /// limiter grants to every holder: on a budget shared with compaction it
635 /// slows compaction too, so a scan that should back off alone gets a
636 /// limiter of its own.
637 pub rate_limiter: Option<alloc::sync::Arc<RateLimiter>>,
638}
639
640impl Default for VerifyOptions {
641 fn default() -> Self {
642 Self {
643 parallelism: 1,
644 throttle: None,
645 rate_limiter: None,
646 }
647 }
648}
649
650impl VerifyOptions {
651 /// Sets the number of SSTs to scan concurrently.
652 #[must_use]
653 pub const fn parallelism(mut self, workers: usize) -> Self {
654 self.parallelism = workers;
655 self
656 }
657
658 /// Sets the per-worker inter-SST throttle delay.
659 #[must_use]
660 pub const fn throttle(mut self, delay: core::time::Duration) -> Self {
661 self.throttle = Some(delay);
662 self
663 }
664
665 /// Reads the scan under `limiter` (see [`Self::rate_limiter`]).
666 ///
667 /// # Examples
668 ///
669 /// ```
670 /// use lsm_tree::rate_limiter::RateLimiter;
671 /// use lsm_tree::verify::VerifyOptions;
672 /// use std::sync::Arc;
673 ///
674 /// // One budget for the scrub and compaction, retuned live.
675 /// let budget = Arc::new(RateLimiter::new(64 << 20));
676 /// let opts = VerifyOptions::default().rate_limiter(Arc::clone(&budget));
677 /// budget.set_rate(16 << 20);
678 /// assert_eq!(opts.rate_limiter.map(|l| l.rate()), Some(16 << 20));
679 /// ```
680 #[must_use]
681 pub fn rate_limiter(mut self, limiter: alloc::sync::Arc<RateLimiter>) -> Self {
682 self.rate_limiter = Some(limiter);
683 self
684 }
685
686 /// Reads the scan at no more than `bytes_per_sec` on-disk bytes per
687 /// second, under a limiter of its own; `0` reads at full speed.
688 #[must_use]
689 pub fn max_bytes_per_sec(self, bytes_per_sec: u64) -> Self {
690 self.rate_limiter(alloc::sync::Arc::new(RateLimiter::new(bytes_per_sec)))
691 }
692}
693
694/// Merges a per-SST partial report into an accumulator.
695/// Renders an ENVIRONMENTAL error into the `io::Error` the integrity reports
696/// carry, keeping the original kind when there is one.
697///
698/// The non-I/O environmental causes (a missing key, a missing dictionary) have
699/// no kind of their own; their message is preserved instead of being flattened
700/// into a decode failure, which a reader would take as evidence about the data.
701fn environmental_as_io(e: crate::Error) -> io::Error {
702 match e {
703 crate::Error::Io(io) => io,
704 other => io::Error::new(
705 io::ErrorKind::Other,
706 alloc::string::ToString::to_string(&other),
707 ),
708 }
709}
710
711fn merge_report(dst: &mut BlockVerifyReport, src: BlockVerifyReport) {
712 dst.sst_files_scanned += src.sst_files_scanned;
713 dst.blocks_scanned += src.blocks_scanned;
714 dst.errors.extend(src.errors);
715 dst.warnings.extend(src.warnings);
716 // An incomplete partial (an SST whose sections were skipped unwalked) taints
717 // the whole merged report: once ANY table could not be fully scanned, the
718 // aggregate `is_ok()` must not claim a clean verdict.
719 dst.incomplete |= src.incomplete;
720}
721
722/// `table`'s punch offset for `bound`, its index walk charged to `limiter`.
723/// The walk reads through the table's own block loader, not the paced file,
724/// so the loader reads each index block a portion at a time and charges each
725/// portion just before reading it: the walk is charged what it really reads,
726/// whatever an unverified handle or top-level key claimed, nothing for a
727/// block the cache serves or the loader refuses unread, and a rate set while
728/// the walk runs applies to the reads still to come. A walk that paces nothing
729/// reads nothing the unlimited walk does not, the file's metadata included.
730fn paced_punch_offset(
731 table: &crate::table::Table,
732 bound: &[u8],
733 limiter: Option<&alloc::sync::Arc<RateLimiter>>,
734) -> crate::Result<u64> {
735 let Some(pacer) = walk_pacer(table, limiter) else {
736 return table.punch_offset_for(bound);
737 };
738 table.punch_offset_paced(
739 bound,
740 crate::table::util::ReadCharge::Maintenance,
741 alloc::sync::Arc::new(pacer),
742 )
743}
744
745/// The pacer a punch-offset walk of `table` reads through, `None` without a
746/// limiter. Attached whatever the limiter's rate when the walk starts, so a
747/// rate set while it runs paces the reads still to come.
748fn walk_pacer(
749 table: &crate::table::Table,
750 limiter: Option<&alloc::sync::Arc<RateLimiter>>,
751) -> Option<LimiterPacer> {
752 let limiter = limiter?;
753 Some(LimiterPacer {
754 limiter: alloc::sync::Arc::clone(limiter),
755 fs: alloc::sync::Arc::clone(&table.fs),
756 path: alloc::sync::Arc::clone(&table.path),
757 file_size: portable_atomic::AtomicU64::new(0),
758 size_state: portable_atomic::AtomicU8::new(SIZE_UNREAD),
759 })
760}
761
762/// The file length is not read yet.
763const SIZE_UNREAD: u8 = 0;
764/// The file length is in `file_size`.
765const SIZE_KNOWN: u8 = 1;
766/// The file length could not be read: portions are charged uncut.
767const SIZE_UNKNOWN: u8 = 2;
768
769/// Paces a walk's block reads through a limiter, each portion cut at the end
770/// of the file.
771struct LimiterPacer {
772 limiter: alloc::sync::Arc<RateLimiter>,
773 fs: alloc::sync::Arc<dyn crate::fs::Fs>,
774 path: alloc::sync::Arc<PathBuf>,
775 /// The length on disk, read on the first paced portion: the table's
776 /// recorded size predates the index sections written after it, and a walk
777 /// that paces nothing (an idle limiter, an index held in memory) reads no
778 /// metadata at all.
779 file_size: portable_atomic::AtomicU64,
780 size_state: portable_atomic::AtomicU8,
781}
782
783impl LimiterPacer {
784 /// The file's length, or `None` when it cannot be read. Read once.
785 fn file_size(&self) -> Option<u64> {
786 use core::sync::atomic::Ordering::Relaxed;
787 match self.size_state.load(Relaxed) {
788 SIZE_KNOWN => Some(self.file_size.load(Relaxed)),
789 SIZE_UNKNOWN => None,
790 _ => {
791 if let Ok(meta) = self.fs.metadata(&self.path) {
792 self.file_size.store(meta.len, Relaxed);
793 self.size_state.store(SIZE_KNOWN, Relaxed);
794 Some(meta.len)
795 } else {
796 // The cut only spares charging bytes past the end;
797 // charging them errs toward a slower walk, never a faster.
798 self.size_state.store(SIZE_UNKNOWN, Relaxed);
799 None
800 }
801 }
802 }
803 }
804}
805
806impl crate::table::util::ReadPacer for LimiterPacer {
807 // A limiter at rate zero reads at full speed: whole reads, uncharged, as
808 // the scan's own reads do. Asked per read, so a rate set while the walk
809 // runs applies to the reads still to come.
810 fn active(&self) -> bool {
811 self.limiter.rate() > 0
812 }
813
814 fn pace(&self, offset: u64, len: u64) {
815 let charge = self
816 .file_size()
817 .map_or(len, |file_size| bytes_in_file(offset, len, file_size));
818 charge_in_portions(&self.limiter, charge);
819 }
820
821 // The index walk's reads steer a latency backoff as the scan's own do.
822 #[cfg(feature = "std")]
823 fn read_took(&self, elapsed: core::time::Duration) {
824 self.limiter.record_read_latency(elapsed);
825 }
826}
827
828/// The bytes a read of `len` at `offset` takes from a file of `file_size`
829/// bytes: the read stops at the end, so a portion past it takes none.
830fn bytes_in_file(offset: u64, len: u64, file_size: u64) -> u64 {
831 // Clamp-to-zero: an offset at or past the end leaves no byte to read.
832 file_size.saturating_sub(offset).min(len)
833}
834
835/// Scans one SST and returns a partial report (`sst_files_scanned == 1`).
836///
837/// Self-contained per table: opens the file through the table's own `Fs`
838/// handle, sizes encryption overhead and ECC params from the table's
839/// descriptor, so it can run on its own worker thread; the `limiter`, when
840/// given, is the one budget every worker draws on.
841fn scan_one_table(
842 table: &crate::table::Table,
843 limiter: Option<&alloc::sync::Arc<RateLimiter>>,
844) -> BlockVerifyReport {
845 let mut report = BlockVerifyReport {
846 sst_files_scanned: 1,
847 ..BlockVerifyReport::default()
848 };
849 let path: &Path = &table.path;
850 let table_id = table.id();
851
852 // Tables whose ECC descriptor decodes to a scheme this build can't apply
853 // can't have their SST-block parity trailers sized (the length isn't
854 // derivable without the scheme), so those sections are skipped with a
855 // warning rather than mis-walked. The self-describing `meta` / `meta_mid`
856 // sections are still walked (parity sized from their own `block_flags`),
857 // so corruption there is NOT downgraded. The per-block read path still
858 // serves the data (framed by data_length, checksum-verified), hence a
859 // warning, not an error.
860 let ecc_unrecognized = table.metadata.ecc_unrecognized;
861 if ecc_unrecognized {
862 log::warn!(
863 "table {table_id} at {}: unrecognized ECC scheme — skipping the \
864 ECC-dependent block sections; recompact to re-stamp with a \
865 supported scheme",
866 path.display(),
867 );
868 report.warnings.push(BlockVerifyWarning::UnrecognizedEcc {
869 table_id,
870 path: path.to_path_buf(),
871 });
872 // The block walk will skip every non-self-describing section (the data
873 // blocks included), so the scan is incomplete: a clean report here would
874 // falsely claim the data verified.
875 report.incomplete = true;
876 }
877
878 // A recognized scheme on a build WITHOUT the ECC codecs: trailers are
879 // consumed for alignment but cannot be verified (parity-only rot stays
880 // invisible) — surface the gap, mirroring the out-of-band walk.
881 #[cfg(not(feature = "page_ecc"))]
882 if table.metadata.ecc_params.is_some() {
883 report
884 .warnings
885 .push(BlockVerifyWarning::ParityUnverifiable {
886 table_id,
887 path: path.to_path_buf(),
888 });
889 }
890
891 // Use each Table's own `Fs` handle (StdFs, MemFs, IoUring, …).
892 // Encryption overhead is per-table (different keys / AEAD suites can attach
893 // to different SSTs), so feed each table's `max_overhead()` separately.
894 let max_enc_overhead = table.encryption.as_ref().map_or(0u32, |e| e.max_overhead());
895 // A restricted view digests / walks only its live suffix: skip the punched
896 // data-block prefix. An ENVIRONMENTAL punch-offset lookup failure (a flaky
897 // partitioned-index read, a refused mount, an exhausted allocator, a
898 // missing key) is recorded as an unreadable-file I/O error and the walk is
899 // skipped — falling back to `0` would walk the hole-punched prefix and
900 // report its zeroed blocks as a false whole-file checksum mismatch on a
901 // healthy restricted table. A failure on the DATA (`Other` / EIO) and a
902 // STRUCTURAL lookup failure both fall back to `0` (walk everything, fail
903 // closed) so an unresolvable or corrupt index cannot exempt blocks.
904 let data_start = match table.restrict_lower_bound() {
905 Some(bound) => match paced_punch_offset(table, bound, limiter) {
906 Ok(offset) => offset,
907 Err(e) if e.is_environmental() => {
908 report.errors.push(BlockVerifyError::SstFileUnreadable {
909 table_id,
910 path: path.to_path_buf(),
911 error: environmental_as_io(e),
912 });
913 return report;
914 }
915 Err(_) => 0,
916 },
917 None => 0,
918 };
919 let layout = SstLayout {
920 max_enc_overhead,
921 ecc: table.metadata.ecc_params,
922 ecc_unrecognized,
923 data_start,
924 };
925 match scan_sst_blocks(&*table.fs, path, table_id, layout, limiter.map(|l| &**l)) {
926 Ok(per_file) => {
927 report.blocks_scanned += per_file.blocks_scanned;
928 report.errors.extend(per_file.errors);
929 }
930 Err(error) => {
931 report.errors.push(BlockVerifyError::SstFileUnreadable {
932 table_id,
933 path: path.to_path_buf(),
934 error,
935 });
936 }
937 }
938 report
939}
940
941/// Walks every block in every SST referenced by the tree's current
942/// version and verifies each block's XXH3 checksum.
943///
944/// Pipeline per SST:
945///
946/// 1. Open the file and parse the SFA trailer to obtain the TOC.
947/// 2. For each TOC section, if its name is in `RAW_FORMAT_SECTIONS` (those
948/// payloads are not `Header`-prefixed and carry no per-section checksum)
949/// validate its structural shape instead of walking blocks. Otherwise
950/// seek to the section's start offset and walk it as a contiguous block
951/// region in `[start, start + length)`.
952/// 3. Inside each block region, decode each block's `Header` (which
953/// validates the header's own XXH3), read the data segment, and
954/// compare a fresh XXH3 over the data against `header.checksum`.
955/// Advance by `Header::header_len(block_type) + data_length` until the
956/// section end. A corrupt header inside a section stops that
957/// section's walk and is reported; the next section is still walked.
958///
959/// This is the read-side scrub primitive: it catches the same bit-rot
960/// signal a live read would surface, ahead of time, with per-block
961/// `(file, offset)` granularity. Decompression and decryption errors
962/// are out of scope here — those depend on per-level/per-block context
963/// (compression policy, encryption key, dictionary) that the scrub
964/// path does not need to reach checksum-level corruption.
965#[must_use]
966pub fn verify_block_checksums(tree: &impl crate::AbstractTree) -> BlockVerifyReport {
967 verify_block_checksums_with(tree, &VerifyOptions::default())
968}
969
970/// Like [`verify_block_checksums`] but with configurable parallelism and
971/// throttle (see [`VerifyOptions`]).
972///
973/// With `parallelism == 1` (default) SSTs are scanned sequentially in table
974/// order. With `> 1`, up to that many worker threads pull SSTs from a shared
975/// cursor and scan them concurrently (each scan is independent — its own file
976/// handle through the table's `Fs`), then their partial reports are merged.
977/// Parallel runs report the same findings as a sequential run; only the order
978/// of `errors` / `warnings` may differ. `rate_limiter` holds every worker
979/// together to a byte rate; `throttle` makes each worker pause between SSTs.
980#[must_use]
981pub fn verify_block_checksums_with(
982 tree: &impl crate::AbstractTree,
983 options: &VerifyOptions,
984) -> BlockVerifyReport {
985 let version = tree.current_version();
986 let tables: Vec<crate::table::Table> = version.iter_tables().cloned().collect();
987 let limiter = options.rate_limiter.as_ref();
988
989 // Parallel scan (std only): up to `parallelism` worker threads pull SSTs from
990 // a shared cursor and scan them concurrently. A `no_std` build has no
991 // threads, so it always takes the serial path below.
992 #[cfg(feature = "std")]
993 {
994 let workers = options.parallelism.max(1).min(tables.len().max(1));
995 if workers > 1 {
996 let cursor = core::sync::atomic::AtomicUsize::new(0);
997 let partials = std::thread::scope(|scope| {
998 let handles: Vec<_> = (0..workers)
999 .map(|_| {
1000 scope.spawn(|| {
1001 let mut local = BlockVerifyReport::default();
1002 let mut idx =
1003 cursor.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1004 while let Some(table) = tables.get(idx) {
1005 merge_report(&mut local, scan_one_table(table, limiter));
1006 // Claim the next SST first; only pause if this
1007 // worker actually has another table to scan.
1008 idx = cursor.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1009 if tables.get(idx).is_some()
1010 && let Some(delay) = options.throttle
1011 {
1012 std::thread::sleep(delay);
1013 }
1014 }
1015 local
1016 })
1017 })
1018 .collect();
1019 handles
1020 .into_iter()
1021 .map(|handle| match handle.join() {
1022 Ok(local) => local,
1023 // A scrub worker panicking is a bug, not a corruption
1024 // finding — propagate rather than drop its SSTs.
1025 Err(payload) => std::panic::resume_unwind(payload),
1026 })
1027 .collect::<Vec<_>>()
1028 });
1029
1030 let mut report = BlockVerifyReport::default();
1031 for partial in partials {
1032 merge_report(&mut report, partial);
1033 }
1034 return report;
1035 }
1036 }
1037
1038 // Serial scan: every `no_std` build, and `std` with `parallelism <= 1`. Scans
1039 // SSTs in deterministic table order, each over its own `Fs` handle.
1040 let mut report = BlockVerifyReport::default();
1041 for (idx, table) in tables.iter().enumerate() {
1042 merge_report(&mut report, scan_one_table(table, limiter));
1043 // Inter-SST throttle (std only — `no_std` has no sleep primitive). Skip
1044 // after the final table so a finished scrub returns promptly instead of
1045 // waiting one extra throttle interval.
1046 #[cfg(feature = "std")]
1047 if idx + 1 < tables.len()
1048 && let Some(delay) = options.throttle
1049 {
1050 std::thread::sleep(delay);
1051 }
1052 #[cfg(not(feature = "std"))]
1053 let _ = idx;
1054 }
1055 report
1056}
1057
1058/// Verifies the per-KV checksum footer of every data block across all SST
1059/// tables in the tree (the paranoid / scrub integrity path).
1060///
1061/// Footer presence is a per-SST property read from each table's descriptor
1062/// (`ParsedMeta::kv_checksum_algo`), not a per-block header flag — SST data
1063/// blocks omit the `block_flags` byte. A table whose descriptor reports no
1064/// footers is skipped wholesale.
1065///
1066/// This is stronger than [`verify_block_checksums`]: for footer-bearing
1067/// tables it decodes each block and recomputes every entry's logical-content
1068/// digest, localising which entry diverged rather than only flagging the
1069/// block. Tables written without per-KV footers carry no per-KV digests and
1070/// are covered by [`verify_block_checksums`] only.
1071///
1072/// Returns the first error encountered (`ChecksumMismatch` on a per-entry
1073/// digest disagreement, or an I/O / decode error). `Ok(())` means every
1074/// per-KV-checked table verified. A tree written entirely with
1075/// `kv_checksums = Off` has no footer-bearing tables, so this is a no-op
1076/// returning `Ok(())`.
1077///
1078/// # Errors
1079///
1080/// Propagates [`crate::Error::ChecksumMismatch`] on a detected per-entry
1081/// corruption, or any I/O / decode error from loading a block.
1082pub fn verify_kv_checksums(tree: &impl crate::AbstractTree) -> crate::Result<()> {
1083 let version = tree.current_version();
1084 for table in version.iter_tables() {
1085 table.verify_kv_checksums()?;
1086 }
1087 Ok(())
1088}
1089
1090/// Out-of-band variant of [`verify_block_checksums`].
1091///
1092/// Walks one SST file directly from a filesystem path, without
1093/// needing a live `Tree` or the version manifest. Intended for
1094/// offline diagnostic tools (`tools/sst-dump verify`, `repair_db`,
1095/// forensics CLIs) that operate on a single file in isolation — for
1096/// example when the manifest itself is corrupt or the surrounding
1097/// tree directory has been moved.
1098///
1099/// Uses [`StdFs`](crate::fs::StdFs) (the only `Fs` backend that
1100/// makes sense for an out-of-band tool — `MemFs` / `IoUring` trees
1101/// never produce files at real filesystem paths) and stamps
1102/// `table_id = 0` in error reports. The caller's downstream
1103/// filtering / logging should refer to the file by path, not by
1104/// table id.
1105///
1106/// AEAD overhead is conservatively assumed to be zero: out-of-band
1107/// tools don't carry the per-table encryption provider that would let
1108/// them recover the real `max_overhead()`. Encrypted SSTs near the
1109/// 256 MiB plaintext ceiling may therefore false-flag as
1110/// [`BlockVerifyError::HeaderCorrupted`]. In practice block sizes are
1111/// typically a few KiB, so this only matters on artificially-
1112/// constructed huge blocks; encrypted-aware verification should go
1113/// through [`verify_block_checksums`] on a live tree.
1114///
1115/// The returned [`BlockVerifyReport`] has `sst_files_scanned == 1`
1116/// (always) plus per-block errors collected during the walk.
1117#[cfg(feature = "std")]
1118#[must_use]
1119pub fn verify_sst_file(path: &std::path::Path) -> BlockVerifyReport {
1120 let fs: alloc::sync::Arc<dyn crate::fs::Fs> = alloc::sync::Arc::new(crate::fs::StdFs);
1121 verify_sst_file_with_fs(&fs, path)
1122}
1123
1124/// As [`verify_sst_file`], but reads `path` through the given filesystem.
1125///
1126/// `pub(crate)` so `repair` can block-verify an SST on the tree's own `Fs`
1127/// before deciding whether to salvage it, rather than assuming `StdFs`.
1128#[cfg(feature = "std")]
1129pub(crate) fn verify_sst_file_with_fs(
1130 fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
1131 path: &std::path::Path,
1132) -> BlockVerifyReport {
1133 verify_sst_file_with_context(fs, path, None, None, 0)
1134}
1135
1136/// As [`verify_sst_file_with_fs`], but with an encryption context for
1137/// ENCRYPTED SSTs and an optional caller-known durable table id. Block headers
1138/// and payload checksums are plaintext, so the section walk itself needs no
1139/// decryption — the provider (and the AAD-bound id) are used only to decode
1140/// the meta block for the per-SST ECC descriptor. This makes the full
1141/// out-of-band walk (every section, raw checksums — which flag even
1142/// ECC-correctable persistent faults) available for encrypted tables, applying
1143/// the same verification standard as the unencrypted path.
1144///
1145/// `known_table_id`: `Some` when the caller knows the durable id out-of-band
1146/// (repair — the SST file name), enforcing the meta payload cross-check even
1147/// on UNENCRYPTED reads so a checksum-clean forged tail meta falls back to the
1148/// intact MID mirror instead of dictating a forged ECC descriptor to the walk;
1149/// `None` for standalone tools with no id knowledge (reports then stamp
1150/// `table_id = 0`).
1151#[cfg(feature = "std")]
1152pub(crate) fn verify_sst_file_with_context(
1153 fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
1154 path: &std::path::Path,
1155 encryption: Option<&alloc::sync::Arc<dyn crate::encryption::EncryptionProvider>>,
1156 known_table_id: Option<crate::TableId>,
1157 // Byte offset to start the DATA-section walk at: `0` for a normal table, the
1158 // punch offset for a tight-space RESTRICTED view (its `[0, data_start)` data
1159 // blocks were hole-punched and read as zeros). A caller holding the table
1160 // supplies it directly; a standalone walk derives it below.
1161 data_start: u64,
1162) -> BlockVerifyReport {
1163 let table_id = known_table_id.unwrap_or(0);
1164 // A caller-known punch offset wins; with none, derive the live frontier of
1165 // a possibly-RESTRICTED SST from its colocated sidecar so a standalone
1166 // walk does not condemn the intentionally punched prefix as corruption.
1167 let derived = if data_start == 0 {
1168 restricted_data_start(fs, path, encryption, known_table_id)
1169 } else {
1170 Ok(data_start)
1171 };
1172 let mut report = BlockVerifyReport {
1173 sst_files_scanned: 1,
1174 ..BlockVerifyReport::default()
1175 };
1176 // Reading the sidecar can fail for reasons that say nothing about the SST
1177 // (a refused mount, an exhausted allocator, a missing key). Walking from
1178 // `0` then condemns the intentionally punched prefix of a healthy
1179 // restricted table, so the walk is skipped and the cause reported instead.
1180 let data_start = match derived {
1181 Ok(offset) => offset,
1182 Err(e) => {
1183 report.errors.push(BlockVerifyError::SstFileUnreadable {
1184 table_id,
1185 path: path.to_path_buf(),
1186 error: environmental_as_io(e),
1187 });
1188 return report;
1189 }
1190 };
1191
1192 // SST blocks omit the block_flags byte, so the parity-trailer presence and
1193 // shard layout the walk must skip come from the per-SST ECC descriptor —
1194 // read it from the meta block. If it can't be determined (corrupt meta, or
1195 // an encrypted SST with no key out-of-band), DO NOT assume disabled:
1196 // walking an ECC-bearing SST without skipping parity trailers mis-aligns
1197 // the scan and reports spurious corruption. Surface the indeterminacy and
1198 // skip the walk.
1199 let mut ecc_unrecognized = false;
1200 let provider = encryption.map(|e| &**e);
1201 let probe = match read_ecc_params_out_of_band(&**fs, path, provider, known_table_id, data_start)
1202 {
1203 Ok(p) => p,
1204 // Real file-open / SFA-trailer failure — preserve the underlying error
1205 // rather than collapsing it into the undeterminable message below.
1206 Err(error) => {
1207 report.errors.push(BlockVerifyError::SstFileUnreadable {
1208 table_id,
1209 path: path.to_path_buf(),
1210 error: error.into(),
1211 });
1212 return report;
1213 }
1214 };
1215 // Both mirrors decode but their FULL metadata disagrees: one is
1216 // forged/rotted to another internally-consistent payload (e.g. a changed
1217 // compression tag with the ECC descriptor untouched). Every byte-level
1218 // check passes on both, so this comparison is the only out-of-band
1219 // detector — a recovery preferring the altered tail would misread every
1220 // data block. Report and keep walking (block-level findings still add
1221 // signal).
1222 if probe.mirrors_diverge {
1223 report.errors.push(BlockVerifyError::TocCorrupted {
1224 table_id,
1225 path: path.to_path_buf(),
1226 section_name: b"meta".to_vec(),
1227 section_offset: 0,
1228 reason: alloc::string::String::from(
1229 "the tail meta and meta_mid mirrors decode to different metadata; \
1230 one copy is forged or rotted behind a re-stamped checksum",
1231 ),
1232 });
1233 }
1234 let ecc = match probe.ecc {
1235 Some(ScrubEcc::Off) => None,
1236 Some(ScrubEcc::Scheme(params)) => Some(params),
1237 // The descriptor decodes to a scheme this build can't apply: the
1238 // SST-block trailer length isn't derivable, so those sections are
1239 // skipped during the walk. The self-describing `meta` / `meta_mid`
1240 // sections still size parity from `block_flags`, so corruption there
1241 // is NOT downgraded. Warn + continue (don't drop the whole scrub).
1242 Some(ScrubEcc::Unrecognized) => {
1243 log::warn!(
1244 "{}: unrecognized ECC scheme — skipping the ECC-dependent block \
1245 sections; recompact to re-stamp with a supported scheme",
1246 path.display(),
1247 );
1248 report.warnings.push(BlockVerifyWarning::UnrecognizedEcc {
1249 table_id,
1250 path: path.to_path_buf(),
1251 });
1252 // The walk below skips the non-self-describing sections (data blocks
1253 // included), so the scan is incomplete: a clean report would falsely
1254 // claim the data verified.
1255 report.incomplete = true;
1256 ecc_unrecognized = true;
1257 None
1258 }
1259 // File + trailer readable, but neither meta block decodes (corrupt
1260 // meta, or an encrypted SST with no key out-of-band). The ECC scheme is
1261 // undeterminable; skip the walk rather than mis-walk an ECC-bearing SST.
1262 None => {
1263 report.errors.push(BlockVerifyError::SstFileUnreadable {
1264 table_id,
1265 path: path.to_path_buf(),
1266 error: io::Error::new(
1267 io::ErrorKind::InvalidData,
1268 "could not decode the SST meta block to determine the ECC scheme \
1269 (corrupt meta, or an encrypted SST with no key out-of-band); \
1270 skipping the block walk — use verify_block_checksums on a live \
1271 tree for ECC-aware verification",
1272 ),
1273 });
1274 return report;
1275 }
1276 };
1277
1278 // A recognized scheme on a build WITHOUT the ECC codecs: the trailers are
1279 // consumed for walk alignment but cannot be verified, so parity-only rot
1280 // stays invisible. Surface that as a warning — the repair gate requires a
1281 // warning-free report, so such a table routes to salvage (whose rewrite is
1282 // parity-less on this build, leaving only verifiable bytes) instead of
1283 // being stamped into a rebuilt manifest with unchecked trailer bytes.
1284 #[cfg(not(feature = "page_ecc"))]
1285 if ecc.is_some() {
1286 report
1287 .warnings
1288 .push(BlockVerifyWarning::ParityUnverifiable {
1289 table_id,
1290 path: path.to_path_buf(),
1291 });
1292 }
1293
1294 // Encrypted blocks legitimately exceed the plaintext data_length cap by
1295 // up to the provider's AEAD overhead (mirroring `Block::from_file`); a
1296 // zero here would false-flag a healthy encrypted block just over the cap
1297 // as HeaderCorrupted and send the whole table to salvage.
1298 let max_enc_overhead =
1299 provider.map_or(0u32, crate::encryption::EncryptionProvider::max_overhead);
1300 let layout = SstLayout {
1301 max_enc_overhead,
1302 ecc,
1303 ecc_unrecognized,
1304 data_start,
1305 };
1306 match scan_sst_blocks(&**fs, path, table_id, layout, None) {
1307 Ok(per_file) => {
1308 report.blocks_scanned = per_file.blocks_scanned;
1309 // extend, NOT assign: the mirror-divergence finding above must
1310 // survive the block walk's own error list.
1311 report.errors.extend(per_file.errors);
1312 }
1313 Err(error) => {
1314 report.errors.push(BlockVerifyError::SstFileUnreadable {
1315 table_id,
1316 path: path.to_path_buf(),
1317 error,
1318 });
1319 }
1320 }
1321
1322 // Explain the parity mismatches the walk just reported, when the evidence
1323 // says they are a mis-identified scheme rather than rot. Gated on those
1324 // mismatches EXISTING for two reasons: a warning on an otherwise-clean
1325 // report would grade the table degraded on its own (`has_warnings()`),
1326 // which is a verdict this diagnostic has no business changing, and the
1327 // probe re-reads a whole region, which no healthy table should pay for.
1328 if let Some(scheme @ ScrubEcc::Scheme(_)) = probe.ecc
1329 && report
1330 .errors
1331 .iter()
1332 .any(|e| matches!(e, BlockVerifyError::EccParityMismatch { .. }))
1333 && codec_suspect_for(
1334 &**fs,
1335 (path, table_id),
1336 scheme,
1337 data_start,
1338 block_data_length_cap(max_enc_overhead),
1339 )
1340 {
1341 report.warnings.push(BlockVerifyWarning::EccCodecSuspect {
1342 table_id,
1343 path: path.to_path_buf(),
1344 });
1345 }
1346
1347 // The blocks verified, but the descriptors that should have described them
1348 // did not: the layout was read off the file instead. Recorded so the table
1349 // is rewritten under a canonical descriptor rather than passing as clean
1350 // and leaving every future reader to infer it again.
1351 if probe.descriptors_unreadable {
1352 report
1353 .warnings
1354 .push(BlockVerifyWarning::EccDescriptorsUnreadable {
1355 table_id,
1356 path: path.to_path_buf(),
1357 });
1358 }
1359
1360 report
1361}
1362
1363/// Per-SST ECC state as seen by the out-of-band scrub.
1364// `PartialEq` + `Copy`: the probe compares the states decoded from the two
1365// meta copies to arbitrate a forged descriptor.
1366#[derive(Clone, Copy, PartialEq, Eq)]
1367#[cfg(feature = "std")]
1368enum ScrubEcc {
1369 /// ECC off — no parity trailer to skip.
1370 Off,
1371 /// A recognized + applicable scheme — size + verify the trailer with it.
1372 Scheme(crate::table::block::EccParams),
1373 /// An ECC scheme this build can't apply (unimplemented / unknown /
1374 /// non-canonical). The trailer length isn't derivable, so the walk must
1375 /// be skipped with a warning.
1376 Unrecognized,
1377}
1378
1379/// The file regions a candidate ECC descriptor is answerable for: every section
1380/// whose blocks it sizes, as half-open `(start, end)` byte ranges.
1381///
1382/// `data_start` skips a restricted view's punched prefix, which reads as zeros
1383/// and would frame as nothing.
1384#[cfg(feature = "std")]
1385fn descriptor_sized_regions(toc: &crate::sfa::Toc, data_start: u64) -> Vec<(u64, u64)> {
1386 // EVERY section the descriptor sizes, taken from the TOC rather than a
1387 // hand-kept list — a section left out is one an impostor can be wrong about
1388 // for free. That is all block-format sections except the self-describing
1389 // ones: `meta` and `meta_mid` carry a `block_flags` byte and derive their
1390 // own parity, so the descriptor says nothing about them and framing them
1391 // under it would mis-size every one of their blocks.
1392 //
1393 // Both index mirrors are in for the same reason the writer emits two: one
1394 // damaged copy must not take the other down, and both are written under the
1395 // same codec, so an intact `tli_tail` can still speak when the head is the
1396 // damaged one.
1397 let mut regions: Vec<(u64, u64)> = Vec::new();
1398 for entry in toc.iter() {
1399 let Some(roles) = expected_section_roles(entry.name()) else {
1400 continue;
1401 };
1402 if roles
1403 .iter()
1404 .any(|role| crate::table::block::Header::has_block_flags(*role))
1405 {
1406 continue;
1407 }
1408 let Some(end) = entry.pos().checked_add(entry.len()) else {
1409 continue;
1410 };
1411 // Only the data section has a punched prefix to skip.
1412 let floor = if entry.name() == b"data" {
1413 data_start
1414 } else {
1415 0
1416 };
1417 regions.push((core::cmp::max(entry.pos(), floor), end));
1418 }
1419 regions
1420}
1421
1422/// Whether the candidate ECC descriptor SIZES this SST's blocks: walking every
1423/// section it is responsible for, the frames must tile each one exactly.
1424///
1425/// This is the only question that can refuse a descriptor, because the trailer
1426/// length is the only thing the block walk cannot proceed without. `Ok(None)`
1427/// when no region holds frames to judge.
1428///
1429/// A region that cannot be READ aborts the whole arbitration with `Err` rather
1430/// than dropping out of it: it may be the one region that would refuse this
1431/// descriptor, and the remaining ones must not decide in its absence. The
1432/// caller turns that into an unreadable-file finding, which is what an I/O
1433/// failure is — never a verdict about the data.
1434#[cfg(feature = "std")]
1435fn arbitrate_by_framing(
1436 file: &dyn crate::fs::FsFile,
1437 toc: &crate::sfa::Toc,
1438 scheme: ScrubEcc,
1439 data_start: u64,
1440) -> crate::io::Result<Option<bool>> {
1441 let regions = descriptor_sized_regions(toc, data_start);
1442 let (mut judged, mut framed_any, mut framed_all) = (false, false, true);
1443 for &(start, end) in ®ions {
1444 if let Some(verdict) = scheme_frames_region(file, scheme, start, end)? {
1445 judged = true;
1446 framed_any |= verdict;
1447 framed_all &= verdict;
1448 }
1449 }
1450 if !judged {
1451 return Ok(None);
1452 }
1453 // EVERY judged region must frame, and no codec match may excuse one that
1454 // does not. A region framing while another does not is either damage in the
1455 // second (the descriptor is right) or a descriptor whose trailer lengths
1456 // coincide for one region's payload sizes and not the other's — RS(4,2) and
1457 // XOR(2,1) agree on many lengths, not all. A match cannot break that tie: it
1458 // says the candidate is CONSISTENT with the bytes it read, never that it is
1459 // the codec that wrote them, so it is no answer to a region that could not
1460 // be framed at all.
1461 if !framed_any || !framed_all {
1462 return Ok(Some(false));
1463 }
1464 // Framing holds everywhere, so the descriptor is kept. The CODEC question —
1465 // whether this scheme is the one that computed the trailers, or merely one
1466 // that sizes them the same — is asked separately and never refuses the
1467 // descriptor. See `codec_disagrees_everywhere` for why.
1468 Ok(Some(true))
1469}
1470
1471/// Whether the candidate's codec disagrees with EVERY trailer it could be
1472/// judged against: no clean block anywhere reproduces its parity.
1473///
1474/// This decides NOTHING about whether the descriptor is used. It is a
1475/// diagnostic, and deliberately so, because refusing a descriptor over the
1476/// codec costs far more than the mistake it would prevent:
1477///
1478/// - Refusing marks the ECC unrecognized, which makes the walk SKIP every
1479/// ECC-bearing section. Nothing about the data is then verified, the repair
1480/// gate grades that `DegradedUnscanned`, and an SST carrying range tombstones
1481/// is EXCLUDED — salvage cannot re-emit range tombstones, so its whole key
1482/// range is lost.
1483/// - Accepting a wrong-but-same-length codec costs a parity recomputation that
1484/// disagrees. Payloads still verify by their own checksums, so the report is
1485/// parity-only, the gate grades it `DegradedButReadable`, and the table is
1486/// kept (or rewritten under fresh parity when salvage can re-emit it).
1487///
1488/// The trailer length is what the walk cannot proceed without, and framing
1489/// already establishes that from the data. The codec identity only decides
1490/// whether parity can be RE-verified, which is a diagnostic property.
1491///
1492/// Scattered mismatches are what rot looks like; a mismatch on every clean
1493/// block in every section, with not one trailer reproduced, is what a
1494/// mis-identified scheme looks like. Only the latter is reported, and only
1495/// alongside the mismatches the walk itself found — see [`codec_suspect_for`],
1496/// which is where that gate and the cost of asking at all are handled.
1497#[cfg(feature = "std")]
1498fn codec_disagrees_everywhere(
1499 (file, table_id): (&dyn crate::fs::FsFile, crate::TableId),
1500 toc: &crate::sfa::Toc,
1501 scheme: ScrubEcc,
1502 data_start: u64,
1503 payload_cap: u64,
1504) -> bool {
1505 let regions = descriptor_sized_regions(toc, data_start);
1506 let mut judged = false;
1507 for &(start, end) in ®ions {
1508 match codec_confirms_region((file, table_id), scheme, start, end, payload_cap) {
1509 // Two different reasons to stay silent, one answer.
1510 //
1511 // AGREEMENT refutes the claim outright: a wrong codec does not
1512 // reproduce a trailer it did not write, except where the codecs
1513 // coincide on that data.
1514 //
1515 // An UNFINISHED region takes the claim off the table instead,
1516 // whatever the finished ones found: the trailer this scheme
1517 // reproduces may be the one behind the cut, and "reproduces none of
1518 // them" cannot be said over a part of the file nobody read.
1519 CodecVerdict::Confirmed | CodecVerdict::Incomplete => return false,
1520 CodecVerdict::Rejected => judged = true,
1521 CodecVerdict::NoEvidence => {}
1522 }
1523 }
1524 judged
1525}
1526
1527/// What one region can say about a candidate ECC codec.
1528///
1529/// Ranked by what the evidence actually proves, which is why the arbitration
1530/// does not simply count matches against mismatches.
1531#[cfg(feature = "std")]
1532#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1533enum CodecVerdict {
1534 /// At least one clean block's trailer matched parity recomputed under the
1535 /// candidate. Says the candidate is CONSISTENT with those bytes — not that
1536 /// it is the codec that wrote them, since same-length schemes reproduce
1537 /// each other's trailer on some payloads.
1538 #[cfg_attr(
1539 not(feature = "page_ecc"),
1540 expect(
1541 dead_code,
1542 reason = "recomputing parity is what produces a match, and it needs the ECC codecs"
1543 )
1544 )]
1545 Confirmed,
1546 /// The region was inspected to its END, at least one clean trailer
1547 /// disagreed, and none matched. Completeness is part of the claim: a
1548 /// traversal cut short says nothing about the blocks behind the cut.
1549 Rejected,
1550 /// The traversal stopped before the region's end and found no match on the
1551 /// way. Distinct from [`Self::NoEvidence`] because the two do not compose
1552 /// the same way: a region with nothing to judge leaves the others to
1553 /// decide, while one that was never finished may hide the very trailer that
1554 /// would refute the table-wide claim, so it takes the claim off the table.
1555 #[cfg_attr(
1556 not(feature = "page_ecc"),
1557 expect(
1558 dead_code,
1559 reason = "the traversal that can stop short is the parity recomputation, which needs the ECC codecs"
1560 )
1561 )]
1562 Incomplete,
1563 /// The region was inspected to its end and held nothing to judge on: no
1564 /// checksum-clean block, or a build without the ECC codecs.
1565 NoEvidence,
1566}
1567
1568/// Whether the candidate's CODEC — not merely its trailer length — matches the
1569/// bytes: parity recomputed over a block's payload must equal the trailer the
1570/// writer stored.
1571///
1572/// Evidence comes ONLY from checksum-clean payloads: a rotted payload
1573/// legitimately disagrees with its original trailer, and counting that as
1574/// "wrong codec" would let damage discard a correct descriptor and hide the
1575/// damage itself behind a skipped walk.
1576///
1577/// ONE clean block that matches is enough to confirm, and no number of
1578/// mismatches outranks it. The question this answers is the report's claim —
1579/// that the scheme reproduces NO trailer — so a reproduced trailer refutes it
1580/// outright, while mismatches beside one are rot under a CORRECT descriptor.
1581///
1582/// Which is why the whole region is scanned rather than exited at the first
1583/// verdict of either kind: a match can sit behind any run of mismatches, and
1584/// missing it turns ordinary scattered rot into a scheme accusation. For the
1585/// same reason a traversal that STOPS early (an unreadable or undecodable
1586/// header, a length past a cap, frames that stop tiling) reports no evidence
1587/// rather than a rejection — the blocks behind the cut were never asked.
1588///
1589/// A match is still not proof of the codec, only consistency with these bytes:
1590/// the encoders are linear transforms, so same-length schemes reproduce each
1591/// other's trailer on some payloads (all-zero parity from identical shards is
1592/// merely the most obvious case). That is why a confirmation is only ever used
1593/// to STAY SILENT, never to endorse a descriptor.
1594///
1595/// `payload_cap` bounds what an untrusted `data_length` may make this read: the
1596/// header it comes from is not yet verified here, and a forged one paired with
1597/// a re-stamped TOC could otherwise ask for a multi-gigabyte allocation and
1598/// take the process down instead of reporting corruption.
1599#[cfg(feature = "std")]
1600fn codec_confirms_region(
1601 (file, table_id): (&dyn crate::fs::FsFile, crate::TableId),
1602 scheme: ScrubEcc,
1603 start: u64,
1604 end: u64,
1605 payload_cap: u64,
1606) -> CodecVerdict {
1607 // `Off` carries no trailer, so there is nothing to recompute: framing is
1608 // the whole of its evidence, and a parity-bearing table cannot frame with
1609 // no trailer at all. `Unrecognized` cannot reproduce any trailer by
1610 // definition, though the reader only ever asks about a scheme it will
1611 // apply.
1612 let params = match scheme {
1613 ScrubEcc::Off => return CodecVerdict::NoEvidence,
1614 ScrubEcc::Scheme(params) => params,
1615 ScrubEcc::Unrecognized => return CodecVerdict::Rejected,
1616 };
1617 #[cfg(not(feature = "page_ecc"))]
1618 {
1619 // No codecs to recompute with. The walk cannot recompute parity either,
1620 // so a same-length impostor is behaviourally identical to the real
1621 // scheme here and there is nothing to confirm.
1622 let _ = (file, table_id, params, start, end, payload_cap);
1623 CodecVerdict::NoEvidence
1624 }
1625 #[cfg(feature = "page_ecc")]
1626 {
1627 use crate::table::block::Header;
1628
1629 // The WHOLE region, not a sample, and no early exit on either verdict:
1630 // the one match that refutes the report can sit behind any run of
1631 // mismatches. The cost is bounded by WHEN this runs — only after the
1632 // walk has already reported a parity mismatch, so a healthy table never
1633 // pays for it.
1634 let mut offset = start;
1635 let mut matched = false;
1636 let mut mismatched = false;
1637 // Every `break` below leaves blocks BEHIND it uninspected, and a
1638 // trailer this scheme reproduces may be among them. The negative answer
1639 // is a claim about the whole region, so it is only available when the
1640 // traversal reached the end.
1641 //
1642 // Seeded from the bounds rather than `false`: an EMPTY region was not
1643 // cut short, there was nothing to cut. Calling it unfinished would let
1644 // one zero-length section silence the diagnosis for the whole table,
1645 // and a restricted view whose punch offset reaches the end of the data
1646 // section produces exactly that.
1647 let mut complete = offset >= end;
1648 while offset < end {
1649 let remaining = end - offset;
1650 let want =
1651 usize::try_from(remaining).map_or(Header::MAX_LEN, |r| r.min(Header::MAX_LEN));
1652 let Ok(buf) = crate::file::read_exact(file, offset, want) else {
1653 break;
1654 };
1655 let Ok(header) = Header::decode_from(&mut &buf[..]) else {
1656 break;
1657 };
1658 // The header is not verified yet, so its `data_length` is untrusted:
1659 // a forged one paired with a re-stamped TOC would otherwise size the
1660 // read below. The walk applies the same cap before trusting a
1661 // length; past it this block is no evidence, not a huge allocation.
1662 if u64::from(header.data_length) > payload_cap {
1663 break;
1664 }
1665 let header_len = Header::header_len(header.block_type) as u64;
1666 let parity_bytes = crate::table::block::expected_parity_len(header.data_length, params);
1667 let parity_len = u64::from(parity_bytes);
1668 // The trailer needs its own bound, and not because of the payload:
1669 // a high-amplification scheme derives one from a SMALL payload. A
1670 // forged `RS(1, 255)` descriptor turns a payload well inside the cap
1671 // into a parity length near `u32::MAX`, so the read below would
1672 // reserve gigabytes before any check could report the forgery. The
1673 // walk applies the same cap; no real configuration exceeds it.
1674 if parity_len > MAX_BLOCK_DATA_LENGTH {
1675 break;
1676 }
1677 // An offset that overflows is a forged geometry, not evidence: stop
1678 // walking rather than judging the codec on it.
1679 let Some(payload_at) = offset.checked_add(header_len) else {
1680 break;
1681 };
1682 let Some(trailer_at) = payload_at.checked_add(u64::from(header.data_length)) else {
1683 break;
1684 };
1685 let Some(next) = trailer_at.checked_add(parity_len) else {
1686 break;
1687 };
1688 if next > end {
1689 break;
1690 }
1691 if let (Ok(payload_size), Ok(trailer_size)) = (
1692 usize::try_from(header.data_length),
1693 usize::try_from(parity_bytes),
1694 ) && payload_size > 0
1695 && trailer_size > 0
1696 {
1697 // A read that fails contributes nothing and is not propagated:
1698 // the walk itself reads these bytes again and reports what it
1699 // finds. Skipping a block here can only cost the EXPLANATION
1700 // for a mismatch, never the descriptor, since no outcome of
1701 // this scan changes which scheme the walk uses.
1702 let payload = crate::file::read_exact(file, payload_at, payload_size);
1703 let trailer = crate::file::read_exact(file, trailer_at, trailer_size);
1704 if let (Ok(payload), Ok(trailer)) = (payload, trailer)
1705 // Only a checksum-clean payload, at its own place, is
1706 // evidence about the codec.
1707 && Checksum::from_raw(crate::hash::hash128(&payload))
1708 == header.payload_checksum(crate::table::block::ChecksumAt::block(
1709 table_id,
1710 header.block_type,
1711 offset,
1712 ))
1713 {
1714 let fresh = match params {
1715 crate::table::block::EccParams::Secded => {
1716 Some(crate::secded::encode_block_parity(&payload))
1717 }
1718 crate::table::block::EccParams::Shard { .. } => {
1719 let (ds, ps) = params.as_shards();
1720 crate::ecc::encode_parity(&payload, ds, ps).ok()
1721 }
1722 };
1723 // Neither answer ends the region: the claim being tested is
1724 // that the scheme reproduces NO trailer, so a match anywhere
1725 // refutes it and a mismatch anywhere is only one more block
1726 // that does not.
1727 if fresh.as_deref() == Some(&trailer[..]) {
1728 matched = true;
1729 } else {
1730 mismatched = true;
1731 }
1732 }
1733 }
1734 offset = next;
1735 complete = offset >= end;
1736 }
1737 // A match outranks any number of mismatches, and needs no completeness:
1738 // one reproduced trailer is positive evidence wherever it was found.
1739 // Scattered mismatches beside it are rot under a CORRECT descriptor, and
1740 // reporting the scheme for them would send the operator recompacting a
1741 // table whose descriptor is right.
1742 if matched {
1743 CodecVerdict::Confirmed
1744 } else if !complete {
1745 CodecVerdict::Incomplete
1746 } else if mismatched {
1747 CodecVerdict::Rejected
1748 } else {
1749 CodecVerdict::NoEvidence
1750 }
1751 }
1752}
1753
1754/// Whether `scheme` sizes an SST block region's frames CONSISTENTLY: walking
1755/// `[start, end)`, every header must decode and the frames must tile the region
1756/// exactly.
1757///
1758/// This is what tells a legitimate ECC descriptor from a forged one. The walk
1759/// advances by `header_len + data_length + parity_len(data_length, scheme)`, so
1760/// a descriptor that mis-states the scheme lands the next read INSIDE the
1761/// previous frame: the bytes there are payload or parity, not a header. A
1762/// descriptor that frames the region end to end is the one the writer used.
1763///
1764/// It judges LAYOUT, not integrity: no checksum is verified, so a rotted block
1765/// under a correct descriptor still frames and stays the block walk's finding
1766/// rather than being reported as a descriptor problem.
1767///
1768/// `Ok(None)` when the region holds no frames to judge (empty, or entirely below
1769/// a restricted table's punch offset) — the caller then has nothing to arbitrate
1770/// on and keeps its existing verdict.
1771///
1772/// A read failure is an `Err`, never `Ok(None)`. The two are opposites: one
1773/// region having nothing to say is normal, while one that could not be READ may
1774/// be the very region that would have refused this descriptor, and dropping it
1775/// lets the others carry the verdict. A transient failure here followed by a
1776/// successful retry in the walk would then report corruption across a healthy
1777/// table.
1778#[cfg(feature = "std")]
1779fn scheme_frames_region(
1780 file: &dyn crate::fs::FsFile,
1781 scheme: ScrubEcc,
1782 start: u64,
1783 end: u64,
1784) -> crate::io::Result<Option<bool>> {
1785 use crate::table::block::Header;
1786
1787 let params = match scheme {
1788 ScrubEcc::Off => None,
1789 ScrubEcc::Scheme(params) => Some(params),
1790 // Not a candidate: an unrecognized descriptor derives no trailer length.
1791 ScrubEcc::Unrecognized => return Ok(None),
1792 };
1793 if end <= start {
1794 return Ok(None);
1795 }
1796 let mut offset = start;
1797 let mut framed = 0usize;
1798 while offset < end {
1799 let remaining = end - offset;
1800 if remaining < Header::MIN_LEN as u64 {
1801 // A tail too short to hold a header: the frames did not tile.
1802 return Ok(Some(false));
1803 }
1804 // A `remaining` past `usize` is certainly past a header, so it clamps
1805 // to the same bound the fitting case does.
1806 let want = usize::try_from(remaining).map_or(Header::MAX_LEN, |r| r.min(Header::MAX_LEN));
1807 let buf = crate::file::read_exact(file, offset, want)?;
1808 let Ok(header) = Header::decode_from(&mut &buf[..]) else {
1809 return Ok(Some(false));
1810 };
1811 let parity_len = params.map_or(0, |p| {
1812 u64::from(crate::table::block::expected_parity_len(
1813 header.data_length,
1814 p,
1815 ))
1816 });
1817 let Some(frame) = (Header::header_len(header.block_type) as u64)
1818 .checked_add(u64::from(header.data_length))
1819 .and_then(|n| n.checked_add(parity_len))
1820 else {
1821 return Ok(Some(false));
1822 };
1823 let Some(next) = offset.checked_add(frame) else {
1824 return Ok(Some(false));
1825 };
1826 if next > end {
1827 return Ok(Some(false));
1828 }
1829 offset = next;
1830 framed += 1;
1831 }
1832 // `offset == end` here: the loop only exits by reaching it or returning.
1833 Ok(if framed == 0 { None } else { Some(true) })
1834}
1835
1836/// Best-effort read of the per-SST ECC state from an SST file's meta
1837/// descriptor, for the out-of-band scrub (no live `Table` to consult).
1838///
1839/// Returns `Ok(Some(state))` when a meta block decodes. The authoritative
1840/// tail `meta` section is tried first; if its block is corrupt / undecodable
1841/// the early `meta_mid` mirror (which the writer emits so one bad meta block
1842/// can't lose the descriptor) is tried next. The `Ok(None)` outer means the
1843/// file and SFA trailer are readable but NEITHER meta block decodes (both
1844/// corrupt, or an encrypted SST whose key the out-of-band tool doesn't have) —
1845/// the scheme is genuinely UNDETERMINABLE. Returns `Err` when the file can't be
1846/// opened or its SFA trailer can't be parsed.
1847///
1848/// The caller MUST NOT treat `Ok(None)` as "ECC disabled": walking an
1849/// ECC-bearing SST without skipping the parity trailers mis-aligns the block
1850/// scan and reports spurious corruption, so the caller skips the walk and
1851/// surfaces the indeterminacy instead.
1852#[cfg(feature = "std")]
1853fn read_ecc_params_out_of_band(
1854 fs: &dyn crate::fs::Fs,
1855 path: &std::path::Path,
1856 encryption: Option<&dyn crate::encryption::EncryptionProvider>,
1857 known_table_id: Option<crate::TableId>,
1858 // Where the DATA walk starts: `0` normally, the punch offset for a
1859 // restricted view. The framing arbitration below reads the same region the
1860 // block walk will, so a punched prefix (which reads as zeros and frames as
1861 // nothing) must be excluded from it too.
1862 data_start: u64,
1863) -> std::io::Result<EccProbe> {
1864 let mut probe = fs.open(path, &crate::fs::FsOpenOptions::new().read(true))?;
1865 let sfa_reader = crate::sfa::Reader::from_reader(&mut probe)
1866 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1867 let toc = sfa_reader.toc();
1868 // Tail `meta` is authoritative for CONTENT; for the ECC descriptor the
1869 // two copies ARBITRATE each other: a single forged copy (its table id
1870 // intact, so the cross-check passes) must not dictate the walk's trailer
1871 // sizing, whether the forge decodes to an unrecognized value or to a
1872 // DIFFERENT recognized state (a forged `Off` would make the walk read
1873 // parity bytes as block headers and condemn a healthy SST). Both copies
1874 // are read; when two decodable copies disagree in ANY way the probe
1875 // fails safe with `Unrecognized` (skip the ECC-dependent sections with a
1876 // warning) — nothing out-of-band can tell which copy is legitimate.
1877 let mut unrecognized_seen = false;
1878 let mut recognized: Vec<ScrubEcc> = Vec::new();
1879 // The FULL decoded mirrors: a tail re-stamped to another internally-consistent
1880 // payload is detectable only by disagreeing with the intact `meta_mid`. Both
1881 // are written from one parameter set, so any decoded difference is corruption
1882 // or a forge. The divergence comparison below masks the ECC descriptor ONLY
1883 // when a mirror is unrecognized; see `mirrors_diverge`.
1884 let mut decoded: Vec<crate::table::meta::ParsedMeta> = Vec::new();
1885 for name in [b"meta".as_slice(), b"meta_mid".as_slice()] {
1886 let Some((pos, len)) = toc.section(name).map(|e| (e.pos(), e.len())) else {
1887 continue;
1888 };
1889 let Ok(size) = u32::try_from(len) else {
1890 continue;
1891 };
1892 let handle = crate::table::BlockHandle::new(crate::table::BlockOffset(pos), size);
1893 // The meta block is the ONLY read here that needs the provider: block
1894 // HEADERS and payload checksums are plaintext, so the section walk
1895 // below works on encrypted files without decrypting anything — only
1896 // the ECC descriptor (inside the meta payload) requires decryption.
1897 // The expected-id cross-check mirrors recovery's: enforced for
1898 // encrypted reads (the AAD binds the id anyway) AND for unencrypted
1899 // reads with a caller-known durable id — a checksum-clean forged tail
1900 // then fails the check and this loop falls back to the intact MID
1901 // mirror, instead of the forged tail dictating a wrong ECC descriptor
1902 // to the walk. Only a standalone id-less diagnostic read skips it.
1903 let expected_id = if encryption.is_some() {
1904 Some(known_table_id.unwrap_or(0))
1905 } else {
1906 known_table_id
1907 };
1908 match crate::table::meta::ParsedMeta::load_with_handle(
1909 probe.as_ref(),
1910 &handle,
1911 expected_id,
1912 encryption,
1913 ) {
1914 Ok(meta) => {
1915 if meta.ecc_unrecognized {
1916 unrecognized_seen = true;
1917 } else {
1918 recognized.push(if let Some(params) = meta.ecc_params {
1919 ScrubEcc::Scheme(params)
1920 } else {
1921 ScrubEcc::Off
1922 });
1923 }
1924 // Keep the FULL decoded mirror; the divergence comparison below
1925 // masks the ECC descriptor only when a mirror is unrecognized.
1926 decoded.push(meta);
1927 }
1928 // An ENVIRONMENTAL read fault must not silently drop a mirror from
1929 // arbitration: with one mirror gone the divergence check goes false
1930 // and could admit an SST under the surviving (possibly forged) copy
1931 // that a retry — or the right key — would expose. Propagate it. A
1932 // read failure on the DATA (a bad sector) or a STRUCTURAL decode
1933 // failure keeps the existing fallback (skip this mirror) so the
1934 // remaining decoded copy can still supply the ECC state.
1935 Err(e) if e.is_environmental() => {
1936 // This probe answers in `io::Result`; a non-I/O environmental
1937 // cause (a missing key or dictionary) carries its own message
1938 // through `Other` rather than being flattened into a decode
1939 // failure the caller would read as damage.
1940 return Err(match e {
1941 crate::Error::Io(io) => io.into(),
1942 other => std::io::Error::other(other),
1943 });
1944 }
1945 Err(_) => {}
1946 }
1947 }
1948 // Two recognized mirrors are compared in FULL: a descriptor disagreement
1949 // between two decodable schemes is a genuine forge. But when EITHER mirror
1950 // carries an unrecognized descriptor, mask the ECC fields: the arbitration
1951 // above tolerates a lone unrecognized sibling, so a descriptor-only forge
1952 // must not condemn a healthy table, while a change to a real field (e.g.
1953 // `created_at`) hidden behind that descriptor must still diverge.
1954 let mirrors_diverge = match decoded.as_slice() {
1955 [a, b] if unrecognized_seen => a.clone().without_ecc() != b.clone().without_ecc(),
1956 [a, b] => a != b,
1957 _ => false,
1958 };
1959 // Set when the layout below is INFERRED from the file because neither
1960 // persisted descriptor could be read. The table walks and verifies, but
1961 // what is on disk is still malformed and must be re-stamped.
1962 let mut descriptors_unreadable = false;
1963 let ecc = match recognized.as_slice() {
1964 // Two decodable copies that agree: trustworthy.
1965 [a, b] if a == b => Some(*a),
1966 // Two decodable copies that DISAGREE: one is forged/rotted and the
1967 // probe cannot tell which — fail safe.
1968 [_, _] => Some(ScrubEcc::Unrecognized),
1969 // One decodable recognized copy with an UNRECOGNIZED sibling. Two
1970 // scenarios are indistinguishable at the descriptor level, so neither
1971 // answer is safe by itself: a healthy table whose one descriptor was
1972 // re-stamped to an unknown kind (trusting the recognized copy is right —
1973 // condemning it is terminal for a range-tombstone SST, which salvage
1974 // cannot re-emit), or a table on a scheme this build does not know whose
1975 // OTHER mirror was re-stamped to a recognized value (trusting it walks
1976 // the blocks with the wrong parity sizing and condemns healthy data).
1977 //
1978 // Decide by the DATA: the descriptor that actually SIZES the blocks is
1979 // the one the writer used. One that does not frame them fails safe.
1980 [one] if unrecognized_seen => Some(
1981 match arbitrate_by_framing(probe.as_ref(), toc, *one, data_start)? {
1982 Some(false) => ScrubEcc::Unrecognized,
1983 // Framed cleanly, or nothing to frame: keep the recognized copy.
1984 Some(true) | None => *one,
1985 },
1986 ),
1987 // One decodable recognized copy, its sibling missing or undecodable.
1988 // Nothing to arbitrate against, and a framing check here would only
1989 // downgrade a genuinely corrupt table's block findings to a skip.
1990 [one] => Some(*one),
1991 // NEITHER mirror names a scheme this build can apply, so there is no
1992 // descriptor to trust — but there is still the file. A parity-bearing
1993 // table cannot frame with no trailer at all, so if `Off` tiles every
1994 // section the blocks carry no parity and the walk can proceed on that
1995 // evidence rather than skipping the table.
1996 //
1997 // Skipping is the expensive answer: it verifies nothing, the repair gate
1998 // grades it `DegradedUnscanned`, and an SST carrying range tombstones is
1999 // then EXCLUDED outright, because salvage cannot re-emit them. Reading
2000 // the sizing off the data costs one framing pass and saves that table.
2001 [] if unrecognized_seen => Some(
2002 match arbitrate_by_framing(probe.as_ref(), toc, ScrubEcc::Off, data_start)? {
2003 Some(true) => {
2004 // Framing says the blocks carry no parity, which is enough
2005 // to walk them. It says nothing about the descriptors, and
2006 // both of those are still unreadable on disk.
2007 descriptors_unreadable = true;
2008 ScrubEcc::Off
2009 }
2010 Some(false) | None => ScrubEcc::Unrecognized,
2011 },
2012 ),
2013 [..] => None,
2014 };
2015 Ok(EccProbe {
2016 ecc,
2017 mirrors_diverge,
2018 descriptors_unreadable,
2019 })
2020}
2021
2022/// Whether the scheme the walk applied disagrees with EVERY trailer it can be
2023/// judged against, for a table whose walk ALREADY reported a parity mismatch.
2024///
2025/// Deliberately not computed by the probe. Its confirming path reads, hashes
2026/// and recomputes parity for a whole region, and the walk then reads the same
2027/// bytes again — so asking it up front would put an extra data-section pass on
2028/// every scrub of every healthy ECC table, to produce a diagnostic that can only
2029/// ever be shown beside a mismatch. Asked here, only tables that already have
2030/// one pay, and the answer is the same.
2031///
2032/// A read failure yields no diagnosis rather than an error: the walk's findings
2033/// stand on their own, and this only annotates them.
2034#[cfg(feature = "std")]
2035fn codec_suspect_for(
2036 fs: &dyn crate::fs::Fs,
2037 (path, table_id): (&std::path::Path, crate::TableId),
2038 scheme: ScrubEcc,
2039 data_start: u64,
2040 payload_cap: u64,
2041) -> bool {
2042 let Ok(mut probe) = fs.open(path, &crate::fs::FsOpenOptions::new().read(true)) else {
2043 return false;
2044 };
2045 let Ok(sfa_reader) = crate::sfa::Reader::from_reader(&mut probe) else {
2046 return false;
2047 };
2048 codec_disagrees_everywhere(
2049 (probe.as_ref(), table_id),
2050 sfa_reader.toc(),
2051 scheme,
2052 data_start,
2053 payload_cap,
2054 )
2055}
2056
2057/// The data-walk start offset for a possibly-RESTRICTED SST verified
2058/// out-of-band with no caller-known punch offset. A valid colocated
2059/// `.restrict-bound` sidecar proves a committed tight-space restriction (it is
2060/// written strictly after the slice's install commits), so an all-zero run
2061/// inside the data section is an intentionally hole-punched consumed block.
2062/// The walk starts past the LAST such run — not at the first nonzero byte:
2063/// the reclaim punches top-down and stops at its first failure, so a partial
2064/// reclaim leaves intact consumed blocks BELOW the holes it did punch, and
2065/// anchoring at the first nonzero byte would put those holes back inside the
2066/// walk and condemn a healthy SST. Without the sidecar the derive returns `0`
2067/// and every zero stays part of the walk, flagging loudly — zeroed-out data on
2068/// an unrestricted table is destruction, not reclaim.
2069///
2070/// `known_table_id`: a sidecar recorded for a DIFFERENT id is ignored — a
2071/// stale or foreign sidecar must not silence zeroed blocks of an unrelated
2072/// table. A standalone tool passes `None`, and the identity then comes from the
2073/// SST's own file name (tables are stored under their numeric id). A name that
2074/// carries no id leaves the sidecar unmatchable, and an unmatchable sidecar
2075/// never skips: the zeros stay in the walk and flag, which is the fail-closed
2076/// direction (destruction misread as reclaim would pronounce the file healthy).
2077///
2078/// Best-effort: any probe or read failure falls back to `0` (the loud
2079/// default). An ENCRYPTED sidecar with no provider reads as corrupt and also
2080/// falls back — encrypted restricted SSTs need the provider-carrying path.
2081#[cfg(feature = "std")]
2082///
2083/// # Errors
2084///
2085/// Propagates an ENVIRONMENTAL sidecar-read failure. Answering `0` for one
2086/// would send the walk over a healthy restricted table's punched prefix and
2087/// report its zeros as corruption; every other outcome (no sidecar, a
2088/// malformed one, an unreadable file) still answers `0`.
2089fn restricted_data_start(
2090 fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
2091 path: &std::path::Path,
2092 encryption: Option<&alloc::sync::Arc<dyn crate::encryption::EncryptionProvider>>,
2093 known_table_id: Option<crate::TableId>,
2094) -> crate::Result<u64> {
2095 // The frontier is derived by WALKING THE FRAMES, never by searching for
2096 // zero runs at arbitrary byte positions. A punch reclaims whole blocks, so
2097 // a reclaimed region is exactly a run of block extents that read as zeros —
2098 // and only positions the walk has proven to be block boundaries are ever
2099 // tested. Scanning raw byte runs instead would accept a live block whose
2100 // VALUE payload happens to end in zeros followed by the next real header,
2101 // moving the frontier past an intact block and making the verifier skip it
2102 // (and any corruption inside it) while still reporting OK.
2103 // The caller's id when it has one, else the id the file name carries.
2104 let expected_id = known_table_id.or_else(|| {
2105 path.file_name()
2106 .and_then(|n| n.to_str())
2107 .and_then(|n| n.parse::<crate::TableId>().ok())
2108 });
2109 let bound = match crate::restrict_bound::read(&**fs, path, encryption.map(|e| &**e)) {
2110 Ok(crate::restrict_bound::SidecarRead::Present(sidecar_id, bound))
2111 if expected_id == Some(sidecar_id) =>
2112 {
2113 bound
2114 }
2115 // Whether this SST is restricted at all is now unknown; answering `0`
2116 // would walk a punched prefix as live data.
2117 Err(e) if e.is_environmental() => return Err(e),
2118 _ => return Ok(0),
2119 };
2120 // Where that bound actually falls, read from the table's own index. This is
2121 // the only authority on the frontier: a committed restriction does NOT make
2122 // every zero region a reclaimed one, because the prefix punch runs
2123 // highest-block-first and stops at its first failure — a failure on the very
2124 // first call leaves no hole at all, and then the first zeros the walk meets
2125 // are destroyed live data. The walk's answer is kept as an upper bound: the
2126 // standalone path cannot know a custom comparator, so an index lookup that
2127 // lands too high can never widen the skip beyond what the geometry shows.
2128 let index_frontier = index_derived_frontier(fs, path, encryption, expected_id, &bound);
2129 // An open / metadata failure here needs no classification: the walk opens
2130 // the same file and reports the real cause, so it never reaches the
2131 // prefix to misjudge it.
2132 let Ok(mut file) = fs.open(path, &crate::fs::FsOpenOptions::new().read(true)) else {
2133 return Ok(0);
2134 };
2135 let Ok(meta) = crate::fs::FsFile::metadata(&*file) else {
2136 return Ok(0);
2137 };
2138 let file_len = meta.len;
2139 // Scan only the DATA section: other sections legitimately contain long
2140 // zero stretches (padding, sparse index entries) that must not move the
2141 // data frontier. Without a readable TOC there is no section to scan.
2142 let Ok(reader) = crate::sfa::Reader::from_reader(&mut file) else {
2143 return Ok(0);
2144 };
2145 let Some((data_pos, data_len)) = reader
2146 .toc()
2147 .iter()
2148 .find(|e| e.name() == b"data")
2149 .map(|e| (e.pos(), e.len()))
2150 else {
2151 return Ok(0);
2152 };
2153 let data_end = data_pos.saturating_add(data_len).min(file_len);
2154 let mut offset = data_pos;
2155 // End of the last block extent proven to be wholly zeroed.
2156 let mut frontier = data_pos;
2157 while offset < data_end {
2158 // A live frame steps over itself WITHOUT its payload being inspected,
2159 // so whatever bytes a value happens to hold can never be mistaken for
2160 // reclaimed space.
2161 if let Some(header) = block_header_at(&*file, offset) {
2162 let step = u64::from(header.on_disk_size());
2163 if step == 0 {
2164 return Ok(0); // Malformed length: refuse to guess a frontier.
2165 }
2166 offset = offset.saturating_add(step);
2167 continue;
2168 }
2169 // No frame here. Either this is reclaimed space or the file is
2170 // damaged; the two are told apart by whether the bytes up to the NEXT
2171 // frame boundary are all zero.
2172 let Some(next) = next_block_header(&*file, offset, data_end) else {
2173 // Nothing frames the rest of the section: a zero tail is reclaimed
2174 // space, anything else is damage this must not paper over.
2175 if extent_is_zeroed(&*file, offset, data_end) {
2176 frontier = data_end;
2177 }
2178 break;
2179 };
2180 if extent_is_zeroed(&*file, offset, next) {
2181 // The FIRST reclaimed gap fixes the frontier, and the derivation
2182 // ends there. A reclaim works top-down from the start of the data
2183 // section, so its holes are the earliest ones in the file — a
2184 // partially completed pass can leave an intact block ahead of them
2185 // (it stops at its first failure), but never live data ahead of a
2186 // LATER hole. So a gap that appears after this one is a live block
2187 // that damage DESTROYED, and letting it advance the frontier too
2188 // would start verification past the loss and pronounce the file
2189 // healthy.
2190 frontier = next;
2191 break;
2192 }
2193 offset = next;
2194 }
2195 // `data_pos` means no validated punched extent was found: nothing to skip.
2196 if frontier == data_pos {
2197 return Ok(0);
2198 }
2199 // Both answers bound the skip: the index says where the restriction ends,
2200 // the walk says how far the reclaimed geometry actually reaches. Skipping
2201 // past either would step over live data.
2202 Ok(index_frontier.map_or(0, |from_index| from_index.min(frontier)))
2203}
2204
2205/// The offset the restriction `bound` maps to in this SST's block index, or
2206/// `None` when the table cannot be opened (which is often the very reason it is
2207/// being verified) — the caller then skips nothing.
2208///
2209/// Opened with the DEFAULT comparator: a standalone verification has no tree
2210/// context. A custom-comparator tree can therefore land on a different block,
2211/// which is why the caller uses this as one of two bounds rather than as the
2212/// frontier outright.
2213#[cfg(feature = "std")]
2214fn index_derived_frontier(
2215 fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
2216 path: &std::path::Path,
2217 encryption: Option<&alloc::sync::Arc<dyn crate::encryption::EncryptionProvider>>,
2218 table_id: Option<crate::TableId>,
2219 bound: &[u8],
2220) -> Option<u64> {
2221 // Through the CALLER's filesystem, not `std::fs`: the table being verified
2222 // may live on any backend.
2223 let checksum =
2224 crate::Checksum::from_raw(crate::repair::compute_table_checksum_from(&**fs, path, 0).ok()?);
2225 let mut params = crate::table::RecoverParams::new(
2226 path.to_path_buf(),
2227 checksum,
2228 table_id.unwrap_or(0),
2229 alloc::sync::Arc::clone(fs),
2230 crate::comparator::default_comparator(),
2231 alloc::sync::Arc::new(crate::cache::Cache::with_capacity_bytes(1_000_000)),
2232 );
2233 params.encryption = encryption.map(alloc::sync::Arc::clone);
2234 let table = crate::table::Table::recover(params).ok()?;
2235 table.punch_offset_for(bound).ok()
2236}
2237
2238/// Decodes the block header at `offset`, or `None` when no frame starts there.
2239#[cfg(feature = "std")]
2240fn block_header_at(
2241 file: &dyn crate::fs::FsFile,
2242 offset: u64,
2243) -> Option<crate::table::block::Header> {
2244 use crate::coding::Decode;
2245 let bytes = crate::file::read_exact(file, offset, crate::table::block::Header::MAX_LEN).ok()?;
2246 crate::table::block::Header::decode_from(&mut &bytes[..]).ok()
2247}
2248
2249/// The offset of the next decodable block header at or after `from`, bounded
2250/// by `end`. Used to bound a candidate reclaimed extent by the frame that
2251/// follows it rather than by an arbitrary byte position.
2252///
2253/// A header always opens with [`crate::file::MAGIC_BYTES`], so candidate
2254/// offsets are found by scanning bulk-read chunks for that first byte and
2255/// decoding only there. A reclaimed prefix is zeros, which contain no candidate
2256/// at all — without this filter a multi-gigabyte prefix would cost one
2257/// header-sized read PER BYTE, which turns the diagnostic verifier into a hang
2258/// on exactly the tight-space files it exists to inspect.
2259#[cfg(feature = "std")]
2260fn next_block_header(file: &dyn crate::fs::FsFile, from: u64, end: u64) -> Option<u64> {
2261 const CHUNK: usize = 64 * 1024;
2262 let lead = *crate::file::MAGIC_BYTES.first()?;
2263 let mut at = from;
2264 while at < end {
2265 let want = usize::try_from(end - at).unwrap_or(CHUNK).min(CHUNK);
2266 let chunk = crate::file::read_exact(file, at, want).ok()?;
2267 // Candidates are located in the chunk but DECODED from the file at
2268 // their absolute offset, so a header whose bytes run past the chunk end
2269 // is still read in full — no chunk overlap is needed.
2270 for (i, _) in chunk.iter().enumerate().filter(|&(_, &b)| b == lead) {
2271 let offset = at.saturating_add(i as u64);
2272 if block_header_at(file, offset).is_some() {
2273 return Some(offset);
2274 }
2275 }
2276 at = at.saturating_add(want as u64);
2277 }
2278 None
2279}
2280
2281/// Whether `[start, end)` reads back as all zeros — the hole-punch signature.
2282#[cfg(feature = "std")]
2283fn extent_is_zeroed(file: &dyn crate::fs::FsFile, start: u64, end: u64) -> bool {
2284 const CHUNK: usize = 64 * 1024;
2285 let mut at = start;
2286 while at < end {
2287 let want = usize::try_from(end - at).unwrap_or(CHUNK).min(CHUNK);
2288 let Ok(bytes) = crate::file::read_exact(file, at, want) else {
2289 return false;
2290 };
2291 if bytes.iter().any(|&b| b != 0) {
2292 return false;
2293 }
2294 at += want as u64;
2295 }
2296 end > start
2297}
2298
2299/// Result of [`read_ecc_params_out_of_band`]: the arbitrated ECC state plus
2300/// whether the two FULLY-decoded meta mirrors disagree in any field.
2301#[cfg(feature = "std")]
2302struct EccProbe {
2303 ecc: Option<ScrubEcc>,
2304 mirrors_diverge: bool,
2305 /// Neither persisted descriptor could be read, and the parity-less layout
2306 /// in `ecc` was INFERRED from the file's own framing. The walk is complete
2307 /// and the payloads verify, but the descriptors on disk are still malformed
2308 /// and only a rewrite re-stamps them.
2309 descriptors_unreadable: bool,
2310}
2311
2312struct PerFileScan {
2313 blocks_scanned: usize,
2314 errors: Vec<BlockVerifyError>,
2315}
2316
2317/// How one SST's blocks are framed, for [`scan_sst_blocks`].
2318#[derive(Clone, Copy)]
2319struct SstLayout {
2320 /// The table's AEAD overhead, which widens the block length cap.
2321 max_enc_overhead: u32,
2322 ecc: Option<crate::table::block::EccParams>,
2323 ecc_unrecognized: bool,
2324 /// Byte offset to START the DATA-section walk at: `0` for a normal table,
2325 /// or the punch offset of a tight-space RESTRICTED view whose
2326 /// `[0, data_start)` data blocks were hole-punched (they read as zeros and
2327 /// would false-flag as corruption). All other sections (index, meta, TLI …)
2328 /// sit past the data region and are always walked in full.
2329 data_start: u64,
2330}
2331
2332impl SstLayout {
2333 /// A table with no encryption, no Page-ECC and no punched prefix.
2334 #[cfg(test)]
2335 const PLAIN: Self = Self {
2336 max_enc_overhead: 0,
2337 ecc: None,
2338 ecc_unrecognized: false,
2339 data_start: 0,
2340 };
2341}
2342
2343/// What the section walks read an SST through: a seekable byte reader.
2344#[cfg(feature = "std")]
2345trait ScanRead: std::io::Read + std::io::Seek {}
2346#[cfg(feature = "std")]
2347impl<T: std::io::Read + std::io::Seek> ScanRead for T {}
2348/// What the section walks read an SST through: a seekable byte reader.
2349#[cfg(not(feature = "std"))]
2350trait ScanRead: io::Read + io::Seek {}
2351#[cfg(not(feature = "std"))]
2352impl<T: io::Read + io::Seek> ScanRead for T {}
2353
2354/// What a read of the file returns: the byte count, or the backend's error.
2355#[cfg(feature = "std")]
2356type FileRead = std::io::Result<usize>;
2357/// What a read of the file returns: the byte count, or the backend's error.
2358#[cfg(not(feature = "std"))]
2359type FileRead = io::Result<usize>;
2360/// The backend's read error.
2361#[cfg(feature = "std")]
2362type FileError = std::io::Error;
2363/// The backend's read error.
2364#[cfg(not(feature = "std"))]
2365type FileError = io::Error;
2366
2367/// The most a rated scan reads or charges in one go, the same portion the
2368/// paced block loader reads in.
2369use crate::table::util::PACE_PORTION;
2370
2371/// Charges `bytes` to `limiter` a portion at a time, so the requests of other
2372/// users of a shared limiter are served between the portions rather than
2373/// behind one long reservation.
2374fn charge_in_portions(limiter: &RateLimiter, bytes: u64) {
2375 for portion in pace_portions(bytes) {
2376 // A scrub has no stop signal, so the wait always ends in a charge.
2377 let stopped = limiter.request_interruptible(portion, || false);
2378 debug_assert!(!stopped, "a scrub is never stopped midway");
2379 }
2380}
2381
2382/// `bytes` split into portions of at most [`PACE_PORTION`].
2383fn pace_portions(bytes: u64) -> impl Iterator<Item = u64> {
2384 let portion = u64::try_from(PACE_PORTION).unwrap_or(u64::MAX);
2385 let mut left = bytes;
2386 core::iter::from_fn(move || {
2387 let next = left.min(portion);
2388 // `next` is at most `left`, so this does not wrap.
2389 left -= next;
2390 (next > 0).then_some(next)
2391 })
2392}
2393
2394/// An SST opened for a scan whose every read of the file is charged to the
2395/// limiter, when there is one, before it is made: the bytes the buffered
2396/// walk pulls in, trailer and TOC included, never a length a header or the
2397/// TOC declares.
2398struct PacedFile<'a> {
2399 inner: Box<dyn crate::fs::FsFile>,
2400 limiter: Option<&'a RateLimiter>,
2401 /// Where the next read starts, and where the file ends.
2402 pos: u64,
2403 len: u64,
2404 /// An error the backend returned after bytes of the same read, which went
2405 /// back first: the next read returns it, as the backend may not fail again.
2406 pending: Option<FileError>,
2407 /// Bytes charged but never read, as when a read failed partway: the next
2408 /// charge takes them off first, wherever the scan reads next.
2409 credit: u64,
2410}
2411
2412impl<'a> PacedFile<'a> {
2413 fn new(
2414 mut inner: Box<dyn crate::fs::FsFile>,
2415 limiter: Option<&'a RateLimiter>,
2416 ) -> io::Result<Self> {
2417 #[cfg(not(feature = "std"))]
2418 use io::{Seek, SeekFrom};
2419 #[cfg(feature = "std")]
2420 use std::io::{Seek, SeekFrom};
2421
2422 let len = inner.seek(SeekFrom::End(0))?;
2423 let pos = inner.seek(SeekFrom::Start(0))?;
2424 Ok(Self {
2425 inner,
2426 limiter,
2427 pos,
2428 len,
2429 pending: None,
2430 credit: 0,
2431 })
2432 }
2433}
2434
2435#[cfg(feature = "std")]
2436impl std::io::Read for PacedFile<'_> {
2437 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2438 self.read_paced(buf)
2439 }
2440}
2441
2442#[cfg(not(feature = "std"))]
2443impl io::Read for PacedFile<'_> {
2444 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2445 self.read_paced(buf)
2446 }
2447}
2448
2449// A seek drops an error held back from a partial read: it belongs to the bytes
2450// after those already handed out, which a scan that seeks away reads again
2451// from wherever it comes back to them, so a fault that persists fails that
2452// read. Kept, it would fail a read of other bytes, under another section.
2453#[cfg(feature = "std")]
2454impl std::io::Seek for PacedFile<'_> {
2455 fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result<u64> {
2456 self.pos = self.inner.seek(to)?;
2457 self.pending = None;
2458 Ok(self.pos)
2459 }
2460}
2461
2462#[cfg(not(feature = "std"))]
2463impl io::Seek for PacedFile<'_> {
2464 fn seek(&mut self, to: io::SeekFrom) -> io::Result<u64> {
2465 self.pos = self.inner.seek(to)?;
2466 self.pending = None;
2467 Ok(self.pos)
2468 }
2469}
2470
2471impl PacedFile<'_> {
2472 /// One read: an error held back from the last one first; under a limiter
2473 /// that waits, at most one portion, charged and then read, a caller wanting
2474 /// more reading again, as `Read` lets it.
2475 fn read_paced(&mut self, buf: &mut [u8]) -> FileRead {
2476 if let Some(e) = self.pending.take() {
2477 return Err(e);
2478 }
2479 // Decided once for the read, its size and its charge alike: a rate
2480 // switched on after this check finds the read uncharged and whole, as
2481 // it was decided, rather than charging the whole buffer at once. A
2482 // rate of zero reads at full speed, as no limiter does.
2483 let limiter = self.limiter.filter(|limiter| limiter.rate() > 0);
2484 let want = if limiter.is_some() {
2485 buf.len().min(PACE_PORTION)
2486 } else {
2487 buf.len()
2488 };
2489 let (head, _) = buf.split_at_mut(want);
2490 let (admitted, from_credit) = self.pace(head.len(), limiter);
2491 let read = self.fill(head, admitted, limiter);
2492 if limiter.is_some() {
2493 // What was charged and not read stays to the file's credit, so a
2494 // read that failed partway is not charged twice for the bytes it
2495 // reads again. A failed read spends the credit it was paid from,
2496 // though: handed back, a run of failing sections would pass the
2497 // same credit along and never wait at the rate.
2498 let failed = read.is_err() || self.pending.is_some();
2499 let got = read.as_ref().map_or(0, |got| *got);
2500 // `fill` reads at most what was admitted, save past the end of the
2501 // file as opened, where nothing was admitted and nothing is owed.
2502 if let Some(unread) = admitted.checked_sub(got) {
2503 // The credit paid at most what was admitted.
2504 let charged_now = admitted - from_credit;
2505 let refund = if failed {
2506 unread.min(charged_now)
2507 } else {
2508 unread
2509 };
2510 self.credit += refund as u64;
2511 }
2512 }
2513 read
2514 }
2515
2516 /// Waits until `limiter`, when there is one, admits a read of up to
2517 /// `want` bytes, counted only up to the file's end, which a read never
2518 /// goes past, and returns how many it admitted and how many of those the
2519 /// credit left from earlier reads paid, which is spent first.
2520 fn pace(&mut self, want: usize, limiter: Option<&RateLimiter>) -> (usize, usize) {
2521 // Zero past the end, where a seek may land: no byte is left to read.
2522 let left = self.len.saturating_sub(self.pos);
2523 let bytes = (want as u64).min(left);
2524 let mut from_credit = 0;
2525 if let Some(limiter) = limiter {
2526 from_credit = self.credit.min(bytes);
2527 self.credit -= from_credit;
2528 charge_in_portions(limiter, bytes - from_credit);
2529 }
2530 // Both at most `want`, so they fit a `usize`.
2531 (
2532 usize::try_from(bytes).unwrap_or(want),
2533 usize::try_from(from_credit).unwrap_or(want),
2534 )
2535 }
2536
2537 /// Reads into `buf` until the `charged` bytes are in or the file ends: a
2538 /// backend may return fewer bytes per call, and every byte charged is then
2539 /// still one read, never charged again by the next call. `limiter` is the
2540 /// one the read was decided under, which alone hears its latency.
2541 fn fill(
2542 &mut self,
2543 buf: &mut [u8],
2544 charged: usize,
2545 #[cfg_attr(
2546 not(feature = "std"),
2547 expect(unused_variables, reason = "latency is timed only with std")
2548 )]
2549 limiter: Option<&RateLimiter>,
2550 ) -> FileRead {
2551 if charged == 0 {
2552 // Nothing admitted: past the end, which reads nothing, or an
2553 // empty buffer.
2554 let read = self.inner.read(buf)?;
2555 self.pos += read as u64;
2556 return Ok(read);
2557 }
2558 #[cfg(not(feature = "std"))]
2559 use io::ErrorKind;
2560 #[cfg(feature = "std")]
2561 use std::io::ErrorKind;
2562
2563 // The time the device takes to answer the charged read is what a
2564 // latency backoff on the limiter steers by; the wait for the limiter
2565 // itself is not part of it.
2566 // no-std: a caller-provided monotonic clock passed to record_read_latency_at
2567 #[cfg(feature = "std")]
2568 let started = std::time::Instant::now();
2569 let mut read = 0;
2570 let mut failed = None;
2571 while read < charged {
2572 let Some(rest) = buf.get_mut(read..charged) else {
2573 break;
2574 };
2575 let got = match self.inner.read(rest) {
2576 Ok(got) => got,
2577 // Retried here: the bytes before it are already taken from
2578 // the file, and an error would make the caller retry into the
2579 // same buffer and lose them.
2580 Err(e) if e.kind() == ErrorKind::Interrupted => continue,
2581 // The bytes already read are handed back first, the error
2582 // with the next read.
2583 Err(e) if read > 0 => {
2584 self.pending = Some(e);
2585 break;
2586 }
2587 Err(e) => {
2588 failed = Some(e);
2589 break;
2590 }
2591 };
2592 if got == 0 {
2593 break;
2594 }
2595 read += got;
2596 self.pos += got as u64;
2597 }
2598 // A read the device answered with an error took its time too: a slow
2599 // failure is congestion the backoff must see. A read decided without
2600 // a rate went uncharged and reports nothing, whatever the rate is now.
2601 #[cfg(feature = "std")]
2602 if let Some(limiter) = limiter {
2603 limiter.record_read_latency(started.elapsed());
2604 }
2605 if let Some(e) = failed {
2606 return Err(e);
2607 }
2608 Ok(read)
2609 }
2610}
2611
2612/// Walks every block of one SST. Returns `Err` only on file-open or
2613/// SFA trailer-parse failure (those make the whole walk impossible).
2614/// Per-block AND per-section errors — corrupt block headers, mismatched
2615/// data checksums, post-header data-read failures, and TOC sections we
2616/// cannot seek to — all land inside `PerFileScan::errors` and never
2617/// cause an early return; the walker proceeds to the next section so
2618/// one bad TOC entry cannot mask corruption in the others.
2619///
2620/// With a `limiter`, every block and raw section is charged its on-disk
2621/// bytes before it is read.
2622fn scan_sst_blocks(
2623 fs: &dyn crate::fs::Fs,
2624 path: &Path,
2625 table_id: TableId,
2626 layout: SstLayout,
2627 limiter: Option<&RateLimiter>,
2628) -> io::Result<PerFileScan> {
2629 let SstLayout {
2630 max_enc_overhead,
2631 ecc,
2632 ecc_unrecognized,
2633 data_start,
2634 } = layout;
2635 use io::BufReader;
2636 #[cfg(not(feature = "std"))]
2637 use io::{Seek, SeekFrom};
2638 #[cfg(feature = "std")]
2639 use std::io::{Seek, SeekFrom};
2640
2641 let mut file = PacedFile::new(
2642 fs.open(path, &crate::fs::FsOpenOptions::new().read(true))?,
2643 limiter,
2644 )?;
2645
2646 // The SFA trailer + TOC live at the tail of the file.
2647 // crate::sfa::Reader::from_reader leaves the cursor at an undefined
2648 // offset; each per-section walk below explicitly seeks to the
2649 // section's `pos()` first so the unknown post-trailer position
2650 // doesn't matter.
2651 // Capture the sfa error's Debug form in the message. crate::io::Error is
2652 // message-only (no source chain) so it stays portable on no_std; the `{:?}`
2653 // repr keeps the original variant (InvalidHeader / InvalidVersion /
2654 // ChecksumMismatch / underlying Io) visible for downstream diagnostics, just
2655 // as a string rather than a downcastable `Error::source()`.
2656 let sfa_reader = crate::sfa::Reader::from_reader(&mut file)
2657 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, alloc::format!("{e:?}")))?;
2658 let toc = sfa_reader.toc();
2659 // SFA TOC layout for an SST. The writer opens the file and
2660 // immediately calls `crate::sfa::Writer::start("data")`, so the first
2661 // TOC entry is named (not unnamed) and covers the data-block
2662 // region. Other named sections, in writer order:
2663 //
2664 // - `data` : block-format (data blocks)
2665 // - `index` : block-format (partitioned index leaf
2666 // blocks; absent for full-index tables,
2667 // emitted before `tli` by
2668 // `PartitionedIndexWriter::finish`)
2669 // - `tli` : block-format (top-level index, both
2670 // full and partitioned variants)
2671 // - `filter` : block-format (filter blocks)
2672 // - `filter_tli` : block-format (top-level filter for
2673 // partitioned filters; absent for full
2674 // filters, emitted after `filter` by
2675 // `PartitionedFilterWriter::finish`)
2676 // - `range_tombstones` : block-format (optional)
2677 // - `meta_mid` : block-format (early mirror of `meta`)
2678 // - `linked_blob_files` : RAW length-prefixed list of u64s
2679 // - `table_version` : RAW single byte
2680 // - `meta_separator` : RAW 4 KiB zero padding
2681 // - `tli_tail` : block-format (tail mirror of `tli`)
2682 // - `meta` : block-format (metadata, authoritative)
2683 //
2684 // Block-format sections are walked block-by-block (each block
2685 // prefixed with the standard `Header`). Raw-format sections carry
2686 // NO per-section checksum (the SFA-trailer checksum covers only
2687 // the TOC bytes), so they get structural shape validation via
2688 // `raw_section_shape_error` instead of a block walk. New section
2689 // names default to "walk" (must be added to `RAW_FORMAT_SECTIONS`
2690 // if they're raw), so a forgotten-to-handle section fails loud
2691 // rather than silently passing a corruption.
2692
2693 let mut reader = BufReader::with_capacity(64 * 1024, file);
2694 let mut blocks_scanned: usize = 0;
2695 let mut errors: Vec<BlockVerifyError> = Vec::new();
2696
2697 // The writer emits sections strictly back-to-back (the first at offset 0,
2698 // each next where the previous ended, the last ending where the TOC
2699 // begins), so the entries must exactly tile `[0, toc_pos)`. The SFA
2700 // trailer checksum is unkeyed, so a re-stamped TOC could otherwise OMIT a
2701 // correctness-bearing entry entirely — `delete_bitmap` and
2702 // `range_tombstones` are optional at parse time, so a vanished section
2703 // resurrects deleted rows while every remaining block still passes its
2704 // byte-level checks. The tiling gap the omission leaves is the only
2705 // out-of-band trace; report it and keep walking the sections that ARE
2706 // present (their findings are still valid).
2707 // Classify catalogue-structure defects (duplicate / shadowing names, tiling
2708 // gaps, unrecognized names, a trailing hole) through the SAME pass
2709 // `toc_may_hide_deletion_section` uses, so the walk's `TocCorrupted` findings
2710 // and the salvage-verdict concealment check can never diverge. A re-stamped
2711 // TOC that duplicates a recognized name, or renames a section to hide it,
2712 // preserves the byte-level checks yet steers `Toc::section` / `ParsedRegions`
2713 // away from the real section — resurrecting the range it masked.
2714 for defect in toc_catalogue_defects(toc, sfa_reader.toc_pos()) {
2715 errors.push(BlockVerifyError::TocCorrupted {
2716 table_id,
2717 path: path.to_path_buf(),
2718 section_name: defect.name,
2719 section_offset: defect.offset,
2720 reason: defect.reason,
2721 });
2722 }
2723 // One reusable data buffer across the whole SST — sized up via
2724 // `resize` per block instead of a fresh `vec![0u8; N]` allocation
2725 // each iteration. On large trees this turns thousands of malloc
2726 // calls into a single growing allocation that settles at the
2727 // largest block size seen.
2728 let mut data_buf: Vec<u8> = Vec::new();
2729 // Same story for the Page-ECC parity trailer (read for alignment and,
2730 // when the codecs are compiled in, verified against fresh parity).
2731 let mut parity_buf: Vec<u8> = Vec::new();
2732
2733 for entry in toc.iter() {
2734 if RAW_FORMAT_SECTIONS.contains(&entry.name()) {
2735 // Raw sections carry NO per-section checksum (the SFA trailer
2736 // checksum covers only the TOC bytes), so validate their SHAPE
2737 // where one is defined; a heal-enabled scrub relies on this walk
2738 // before restamping the manifest digest, and skipping a broken
2739 // blob-link list would launder it. Rot INSIDE a structurally
2740 // valid payload (a flipped id byte) remains undetectable here —
2741 // these sections have no integrity bytes to check against.
2742 match raw_section_shape_error(&mut reader, entry.name(), entry.pos(), entry.len()) {
2743 Ok(Some(reason)) => {
2744 errors.push(BlockVerifyError::TocCorrupted {
2745 table_id,
2746 path: path.to_path_buf(),
2747 section_name: entry.name().to_vec(),
2748 section_offset: entry.pos(),
2749 reason,
2750 });
2751 }
2752 Ok(None) => {}
2753 // A transient read validating a raw section is retryable I/O, not
2754 // corruption: record it as a DataReadError the repair verdict aborts on.
2755 Err(e) => {
2756 errors.push(BlockVerifyError::DataReadError {
2757 table_id,
2758 path: path.to_path_buf(),
2759 offset: entry.pos(),
2760 data_length: 0,
2761 error: e,
2762 });
2763 }
2764 }
2765 continue;
2766 }
2767 // A restricted view's punched data-block prefix reads as zeros; start
2768 // the DATA walk at `data_start` so those blocks are not framed (only the
2769 // data section is punched — every other section is walked in full). The
2770 // straddling block at `data_start` is intact (the punch begins at its
2771 // boundary), so `start.max(data_start)` lands on a real block header.
2772 let start = if entry.name() == b"data" {
2773 entry.pos().max(data_start)
2774 } else {
2775 entry.pos()
2776 };
2777 // `checked_add` (not `saturating_add`) so a corrupted or
2778 // forged TOC length cannot silently collapse to `u64::MAX`
2779 // and let the walk treat the whole address space as one
2780 // section. On overflow we surface the section as a
2781 // file-level `TocCorrupted` and skip walking it — the other
2782 // (still-walkable) sections of the same SST are honoured.
2783 // `TocCorrupted` rather than `HeaderCorrupted` because the
2784 // failure is at the section-catalogue layer, not inside any
2785 // individual block.
2786 let Some(end) = entry.pos().checked_add(entry.len()) else {
2787 // Report the DECLARED TOC offset, not the walk start: the overflow
2788 // is computed from `entry.pos()`, and for a restricted `data`
2789 // section `start` is the live frontier — a different number, which
2790 // would send repair and forensic readers to the wrong entry.
2791 let declared = entry.pos();
2792 errors.push(BlockVerifyError::TocCorrupted {
2793 table_id,
2794 path: path.to_path_buf(),
2795 section_name: entry.name().to_vec(),
2796 section_offset: declared,
2797 reason: format!(
2798 "section length {} overflows u64 when added to start offset {declared}",
2799 entry.len(),
2800 ),
2801 });
2802 continue;
2803 };
2804 // Mid-walk seek failure: a forged offset still seeks fine, so a seek
2805 // failure is a TRANSIENT I/O fault, not catalogue corruption. Record it as
2806 // a `DataReadError` (which carries the I/O kind) so the repair verdict
2807 // treats it as retryable and aborts, rather than routing a healthy SST
2808 // through salvage over a flaky read. Keep walking other sections (the
2809 // finding still surfaces; the caller decides).
2810 if let Err(e) = reader.seek(SeekFrom::Start(start)) {
2811 errors.push(BlockVerifyError::DataReadError {
2812 table_id,
2813 path: path.to_path_buf(),
2814 offset: start,
2815 data_length: 0,
2816 error: e.into(),
2817 });
2818 continue;
2819 }
2820 // Skip a section name this build does not know: its role expectation is
2821 // unknowable, so a walk would prove nothing. `toc_catalogue_defects`
2822 // above already reported it as a `TocCorrupted` finding (a re-stamped
2823 // TOC can RENAME a known section out of every reader's sight while its
2824 // blocks still pass their byte-level checks).
2825 let Some(expected_roles) = expected_section_roles(entry.name()) else {
2826 continue;
2827 };
2828 let mut ctx = WalkCtx {
2829 reader: &mut reader,
2830 table_id,
2831 path,
2832 data_buf: &mut data_buf,
2833 parity_buf: &mut parity_buf,
2834 blocks_scanned: &mut blocks_scanned,
2835 errors: &mut errors,
2836 max_data_length: block_data_length_cap(max_enc_overhead),
2837 ecc,
2838 ecc_unrecognized,
2839 expected_roles,
2840 };
2841 walk_block_region(&mut ctx, start, end);
2842 }
2843
2844 Ok(PerFileScan {
2845 blocks_scanned,
2846 errors,
2847 })
2848}
2849
2850/// SFA TOC section names whose payload is NOT a sequence of `Block`s
2851/// (i.e. NOT prefixed with the standard `Header`). These sections carry NO
2852/// per-section checksum (the SFA-trailer checksum covers only the TOC
2853/// bytes), so the walk validates their SHAPE via
2854/// [`raw_section_shape_error`] instead of decoding blocks. Every other
2855/// section (`data` / `tli` / `tli_tail` / `index` / `filter_tli` /
2856/// `filter` / `range_tombstones` / `meta` / `meta_mid`) is a
2857/// `Header`-prefixed block run and gets walked. See `scan_sst_blocks` for
2858/// the full section catalogue and the writer-side source of truth.
2859///
2860/// `meta_separator` is the 4 KiB zero-padding section the writer
2861/// emits between the MID and TAIL meta blocks so a single bad
2862/// filesystem sector cannot take out both copies — it carries no
2863/// blocks and must be skipped here, otherwise the walker would try
2864/// to decode zeros as a `Header` and report a spurious
2865/// `HeaderCorrupted` on every clean SST.
2866const RAW_FORMAT_SECTIONS: &[&[u8]] = &[b"linked_blob_files", b"table_version", b"meta_separator"];
2867
2868/// Block ROLE(S) the writer emits into each named block-format SFA section.
2869/// The walk cross-checks every decoded header against its section: a
2870/// checksum-clean block whose `block_type` was re-stamped (a filter block
2871/// relabeled as Data) passes every byte-level check, so this is the only
2872/// out-of-band detector before the heal's digest reconciliation would
2873/// launder the forge into the manifest.
2874///
2875/// `None` for a section name this build does not know — the CALLER fails
2876/// closed on it (an error, not a skipped check): the SFA trailer checksum is
2877/// unkeyed, so a re-stamped TOC can RENAME a known section (hiding it from
2878/// every reader — vanished range tombstones resurrect deleted ranges) while
2879/// each block inside still passes its byte-level checks. A future section
2880/// name therefore requires extending this map in the same change that adds
2881/// the writer section.
2882///
2883/// This role check is BYTE-LEVEL only. A section whose block is
2884/// checksum-clean and correctly-roled but whose PAYLOAD was re-stamped to
2885/// another structurally valid value (a redirected `locator`, a shrunk
2886/// `zone_map` range, a widened `seqno_bounds`) passes here yet still lies to
2887/// the read path. Those SEMANTIC cross-checks — comparing the section's
2888/// decoded content against the blocks it summarizes — live on `Table`
2889/// (`verify_locator` / `verify_zone_map` / `verify_seqno_bounds` /
2890/// `verify_tli_mirrors` / `verify_block_entry_counts`) and are driven by the
2891/// repair verdict and the heal digest reconciliation, not by this walk.
2892fn expected_section_roles(name: &[u8]) -> Option<&'static [crate::table::block::BlockType]> {
2893 use crate::table::block::BlockType;
2894 Some(match name {
2895 // A columnar table's data section holds row groups, each a page
2896 // directory followed by its pages and, for a group of several row
2897 // pages, its zone block; a row-major table's holds data blocks. The
2898 // single-block columnar layout that preceded pages is no longer
2899 // written, so a block in that role here is misrouted.
2900 b"data" => &[
2901 BlockType::Data,
2902 BlockType::ColumnPageDirectory,
2903 BlockType::ColumnPage,
2904 BlockType::ColumnZones,
2905 ],
2906 // `filter_tli` is the top-level index OVER filter partitions — the
2907 // writer emits it with the Index role (same encoding as the data
2908 // TLI), so expecting Filter here would flag a healthy
2909 // partitioned-filter SST as corrupt.
2910 b"index" | b"tli" | b"tli_tail" | b"filter_tli" => &[BlockType::Index],
2911 b"filter" => &[BlockType::Filter],
2912 b"range_tombstones" => &[BlockType::RangeTombstone],
2913 b"meta" | b"meta_mid" => &[BlockType::Meta],
2914 b"block_layout" => &[BlockType::BlockLayout],
2915 b"seqno_bounds" => &[BlockType::SeqnoBounds],
2916 b"zone_map" => &[BlockType::ZoneMap],
2917 b"delete_bitmap" => &[BlockType::DeleteBitmap],
2918 b"owned_blob_objects" => &[BlockType::OwnedBlobObjects],
2919 b"locator" => &[BlockType::Locator],
2920 _ => return None,
2921 })
2922}
2923
2924/// One structural defect in the SFA TOC catalogue: a section a reader could not
2925/// reach or that hides another. Carries the offending entry's name, its declared
2926/// offset, and a human-readable reason.
2927struct TocCatalogueDefect {
2928 name: Vec<u8>,
2929 offset: u64,
2930 reason: String,
2931}
2932
2933/// Classifies every structural defect in the TOC catalogue in one pass: a
2934/// duplicate / shadowing name, a gap in the `[0, toc_pos)` tiling, an
2935/// unrecognized (renamed) name, or a trailing hole. Empty when the catalogue
2936/// tiles the whole data region with unique, recognized names.
2937///
2938/// The single source of truth for what a valid catalogue looks like:
2939/// [`scan_sst_blocks`] turns each defect into a `TocCorrupted` finding and
2940/// [`toc_may_hide_deletion_section`] fails closed on any, so the two can never
2941/// disagree. The recognized-name set is `expected_section_roles` ∪
2942/// [`RAW_FORMAT_SECTIONS`]. A `pos + len` overflow stops the tiling scan (the
2943/// per-section walk reports that entry via its own `checked_add`), so it is not
2944/// duplicated here.
2945fn toc_catalogue_defects(toc: &crate::sfa::Toc, toc_pos: u64) -> Vec<TocCatalogueDefect> {
2946 let mut defects = Vec::new();
2947 let mut expected_pos: u64 = 0;
2948 // A handful of section names — a linear scan keeps this no-std-clean.
2949 let mut seen: Vec<&[u8]> = Vec::new();
2950 for entry in toc.iter() {
2951 let name = entry.name();
2952 if seen.contains(&name) {
2953 defects.push(TocCatalogueDefect {
2954 name: name.to_vec(),
2955 offset: entry.pos(),
2956 reason: format!(
2957 "duplicate TOC section name {:?}; a renamed section can shadow \
2958 another and hide it from the readers that look it up by name",
2959 alloc::string::String::from_utf8_lossy(name),
2960 ),
2961 });
2962 } else {
2963 seen.push(name);
2964 }
2965 if entry.pos() != expected_pos {
2966 defects.push(TocCatalogueDefect {
2967 name: name.to_vec(),
2968 offset: entry.pos(),
2969 reason: format!(
2970 "section starts at {} but the previous section ended at \
2971 {expected_pos}; the gap hides an omitted TOC entry",
2972 entry.pos(),
2973 ),
2974 });
2975 }
2976 if expected_section_roles(name).is_none() && !RAW_FORMAT_SECTIONS.contains(&name) {
2977 defects.push(TocCatalogueDefect {
2978 name: name.to_vec(),
2979 offset: entry.pos(),
2980 reason: String::from(
2981 "unrecognized block-format section name; a renamed TOC entry \
2982 hides a known section from every reader",
2983 ),
2984 });
2985 }
2986 let Some(end) = entry.pos().checked_add(entry.len()) else {
2987 expected_pos = u64::MAX;
2988 break;
2989 };
2990 expected_pos = end;
2991 }
2992 if expected_pos != toc_pos {
2993 defects.push(TocCatalogueDefect {
2994 name: b"<tiling>".to_vec(),
2995 offset: expected_pos,
2996 reason: format!(
2997 "sections end at {expected_pos} but the TOC begins at {toc_pos}; a \
2998 trailing TOC entry was omitted or truncated",
2999 ),
3000 });
3001 }
3002 defects
3003}
3004
3005/// Whether the SST's TOC catalogue could HIDE an optional deletion section
3006/// (`range_tombstones` / `delete_bitmap`) from the name-based readers. These
3007/// sections are optional at parse time, so an unkeyed re-stamp that OMITS,
3008/// RENAMES, or SHADOWS one leaves the parsed table reporting no deletions
3009/// while every remaining block still passes its byte-level checks — a positional
3010/// salvage would then re-emit the suppressed rows as live.
3011///
3012/// Returns `true` for the concealment classes: a duplicate/shadowing name, a
3013/// gap or trailing hole in the `[0, toc_pos)` tiling, an unrecognized (renamed)
3014/// name, or a length overflow. Returns `false` only when the catalogue tiles
3015/// the whole data region with UNIQUE, RECOGNIZED names — then no section is
3016/// hidden and the physical absence of any deletion section is established.
3017///
3018/// The recognized-name set mirrors the walk in [`scan_sst_blocks`] exactly
3019/// (`expected_section_roles` ∪ [`RAW_FORMAT_SECTIONS`]), so a healthy table
3020/// grades `false`.
3021///
3022/// Consumed by salvage-mode repair: a `Corrupt` verdict caused by one of these
3023/// classes must be QUARANTINED, not salvaged, because the positional salvage
3024/// walk reopens the same forged catalogue and resurrects the suppressed rows.
3025///
3026/// This catches only concealment that DISTURBS the catalogue (a missing,
3027/// duplicated, or unrecognized name, or a tiling gap). A relabel that keeps the
3028/// catalogue uniquely named and perfectly tiled — a deletion section RENAMED to
3029/// an unused recognized name with its block re-roled — grades `false` here; it
3030/// is caught instead inside salvage, which fails closed when the open degrades a
3031/// rebuildable section that did not decode as its claimed type (see
3032/// `Table::salvage_degraded_a_rebuildable_section`).
3033pub(crate) fn toc_may_hide_deletion_section(toc: &crate::sfa::Toc, toc_pos: u64) -> bool {
3034 !toc_catalogue_defects(toc, toc_pos).is_empty()
3035}
3036
3037/// Structural validation for the raw (non-block-format) sections; returns a
3038/// human-readable reason when the section's payload cannot have the shape
3039/// the writer emits.
3040///
3041/// - `linked_blob_files`: `u32 count` followed by `count` records, each 32
3042/// fixed bytes then a first and a last key, each prefixed by its `u16`
3043/// length; the records must end exactly at the section's end.
3044/// - `table_version`: exactly one byte.
3045/// - `meta_separator`: pure padding, any content is acceptable.
3046///
3047/// This is SHAPE validation only: these sections carry no checksum, so rot
3048/// inside a structurally valid payload is undetectable out-of-band.
3049///
3050/// `Err` is a TRANSIENT read/seek fault (retryable I/O), kept distinct from a
3051/// structural shape defect (`Ok(Some(reason))`) so the caller can route it to an
3052/// I/O finding the repair verdict treats as retryable rather than as corruption.
3053fn raw_section_shape_error<R: ScanRead>(
3054 reader: &mut R,
3055 name: &[u8],
3056 pos: u64,
3057 len: u64,
3058) -> Result<Option<String>, io::Error> {
3059 #[cfg(not(feature = "std"))]
3060 use io::SeekFrom;
3061 #[cfg(feature = "std")]
3062 use std::io::SeekFrom;
3063
3064 match name {
3065 b"linked_blob_files" => {
3066 if len < 4 {
3067 return Ok(Some(format!(
3068 "linked_blob_files section is {len} bytes, too short for its count prefix"
3069 )));
3070 }
3071 reader.seek(SeekFrom::Start(pos))?;
3072 let mut count_le = [0u8; 4];
3073 reader.read_exact(&mut count_le)?;
3074 let count = u32::from_le_bytes(count_le);
3075 // Walk the records without holding them: each is its fixed fields,
3076 // then two keys each preceded by a two-byte length. The walk must
3077 // end exactly at the section's end.
3078 let fixed = crate::table::writer::LINKED_BLOB_FILE_FIXED_LEN as u64;
3079 let past_end = |record: u32| {
3080 Some(format!(
3081 "blob-link count {count} disagrees with the section length {len}: \
3082 record {record} runs past it"
3083 ))
3084 };
3085 let mut consumed = 4u64;
3086 for record in 0..count {
3087 let mut next = consumed + fixed;
3088 if next > len {
3089 return Ok(past_end(record));
3090 }
3091 reader.seek(SeekFrom::Current(fixed.cast_signed()))?;
3092 for _ in 0..2 {
3093 if next + 2 > len {
3094 return Ok(past_end(record));
3095 }
3096 let mut key_len_le = [0u8; 2];
3097 reader.read_exact(&mut key_len_le)?;
3098 let key_len = u64::from(u16::from_le_bytes(key_len_le));
3099 next += 2 + key_len;
3100 if next > len {
3101 return Ok(past_end(record));
3102 }
3103 reader.seek(SeekFrom::Current(key_len.cast_signed()))?;
3104 }
3105 consumed = next;
3106 }
3107 if consumed != len {
3108 return Ok(Some(format!(
3109 "blob-link count {count} disagrees with the section length {len} \
3110 ({consumed} bytes of records)"
3111 )));
3112 }
3113 Ok(None)
3114 }
3115 b"table_version" => {
3116 Ok((len != 1).then(|| format!("table_version section is {len} bytes, expected 1")))
3117 }
3118 // Padding: carries no data, nothing to validate.
3119 _ => Ok(None),
3120 }
3121}
3122
3123/// Plaintext upper bound on a single block's on-disk data segment
3124/// length, mirroring `table::block::MAX_DECOMPRESSION_SIZE` (256 MiB).
3125/// Encrypted blocks legitimately exceed this by up to the AEAD
3126/// provider's `max_overhead()`; see `block_data_length_cap` for the
3127/// effective per-walk cap that adds that overhead in.
3128const MAX_BLOCK_DATA_LENGTH: u64 = 256 * 1024 * 1024;
3129
3130/// Effective `data_length` cap for one scan, mirroring the size
3131/// validation in `Block::from_file`: plaintext cap + the table's AEAD
3132/// `max_overhead()` (0 when encryption is disabled). A value above
3133/// this is treated as `HeaderCorrupted` regardless of TOC bounds,
3134/// defending against DoS-by-allocation if both the block header and
3135/// the enclosing TOC entry are simultaneously corrupted / forged.
3136fn block_data_length_cap(max_enc_overhead: u32) -> u64 {
3137 MAX_BLOCK_DATA_LENGTH + u64::from(max_enc_overhead)
3138}
3139
3140/// Walks the contiguous block range `[start_offset, end_offset)`,
3141/// decoding each block's header (which validates the header's own
3142/// XXH3) and then re-hashing the data segment against
3143/// `header.checksum`. Stops at the first un-parseable header inside
3144/// the range — that block is reported as `HeaderCorrupted` and the
3145/// rest of the range is skipped because subsequent offsets become
3146/// unrecoverable without a valid length field.
3147/// Mutable cursor + scratch state threaded through `walk_block_region`.
3148/// Bundles the per-walk accumulators (file cursor, reused data
3149/// buffer, counters, error sink) into one borrow so the function
3150/// signature stays under clippy's argument-count cap.
3151struct WalkCtx<'a, 'l> {
3152 reader: &'a mut io::BufReader<PacedFile<'l>>,
3153 table_id: TableId,
3154 path: &'a Path,
3155 data_buf: &'a mut Vec<u8>,
3156 /// Reused buffer for each block's Page-ECC parity trailer: consumed for
3157 /// walk alignment and, on a build with the ECC codecs, verified against
3158 /// parity freshly recomputed over the payload.
3159 parity_buf: &'a mut Vec<u8>,
3160 blocks_scanned: &'a mut usize,
3161 errors: &'a mut Vec<BlockVerifyError>,
3162 /// Effective `data_length` cap (plaintext limit + AEAD overhead).
3163 /// Matches the bound `Block::from_file` applies on the read path,
3164 /// so the scrub does not false-flag legitimate encrypted blocks
3165 /// near the 256 MiB plaintext limit as `HeaderCorrupted`.
3166 max_data_length: u64,
3167 /// Per-SST Page-ECC shard layout. SST blocks (`Data` / `Index` / `Filter` /
3168 /// `RangeTombstone`) omit the `block_flags` byte, so their parity-trailer
3169 /// presence AND shard layout are NOT derivable from the header — both come
3170 /// from this table-wide descriptor scheme. When `Some`, each such block
3171 /// carries `expected_parity_len(data_length, scheme)` parity bytes after
3172 /// the payload that the walk must skip (sized by the scheme) to stay
3173 /// aligned. Meta / Manifest / `ManifestFooter` blocks keep the byte and
3174 /// self-describe parity via their `ECC_PARITY` bit, sized with the fixed
3175 /// RS(4,2) layout the writer uses for them, regardless of this field.
3176 ecc: Option<crate::table::block::EccParams>,
3177 /// `true` when the table's ECC descriptor decodes to a scheme this build
3178 /// can't apply. The trailer length of its SST blocks (`Data` / `Index` /
3179 /// `Filter` / `RangeTombstone`) isn't derivable, so those sections are
3180 /// skipped (the caller warns once). Self-describing sections (`meta` /
3181 /// `meta_mid`) still size parity from `block_flags` and ARE walked.
3182 ecc_unrecognized: bool,
3183 /// Roles the current section's blocks may legitimately carry (from
3184 /// [`expected_section_roles`]; the caller fails closed on an unknown
3185 /// name before building this context). A decoded header whose
3186 /// `block_type` is not in the list is reported — see the helper's docs
3187 /// for why this check is load-bearing.
3188 expected_roles: &'static [crate::table::block::BlockType],
3189}
3190
3191fn walk_block_region(ctx: &mut WalkCtx<'_, '_>, start_offset: u64, end_offset: u64) {
3192 #[cfg(not(feature = "std"))]
3193 use io::Read;
3194 #[cfg(feature = "std")]
3195 use std::io::Read;
3196
3197 let mut offset = start_offset;
3198
3199 while offset < end_offset {
3200 // Confine reads to the declared section before touching
3201 // Header::decode_from. Without this pre-check, a TOC entry
3202 // whose `len` puts `end_offset` inside the first block's
3203 // header region would let `decode_from` consume up to
3204 // `header_len` bytes — reading past the section boundary
3205 // into the next section's payload, where random bytes might
3206 // happen to parse as a "valid" header and silently corrupt
3207 // the walk. Treat the under-sized tail as `HeaderCorrupted`
3208 // and stop this section's walk; subsequent sections still
3209 // run because `walk_block_region` returns rather than
3210 // bubbling the error up.
3211 let remaining_in_section = end_offset - offset;
3212 // Lower bound: the header is at least MIN_LEN (the exact length, with
3213 // or without the block_flags byte, is known only after decode).
3214 if remaining_in_section < Header::MIN_LEN as u64 {
3215 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3216 table_id: ctx.table_id,
3217 path: ctx.path.to_path_buf(),
3218 offset,
3219 reason: format!(
3220 "section has only {remaining_in_section} bytes left at this offset, \
3221 less than Header::MIN_LEN = {}",
3222 Header::MIN_LEN,
3223 ),
3224 });
3225 return;
3226 }
3227 let header = match Header::decode_from(ctx.reader) {
3228 Ok(h) => h,
3229 // A TRANSIENT read fault decoding the header is retryable, not
3230 // corruption: record it as a DataReadError so the repair verdict
3231 // aborts instead of salvaging a healthy table over a flaky read.
3232 Err(crate::Error::Io(e)) => {
3233 ctx.errors.push(BlockVerifyError::DataReadError {
3234 table_id: ctx.table_id,
3235 path: ctx.path.to_path_buf(),
3236 offset,
3237 data_length: 0,
3238 error: e,
3239 });
3240 return;
3241 }
3242 Err(e) => {
3243 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3244 table_id: ctx.table_id,
3245 path: ctx.path.to_path_buf(),
3246 offset,
3247 reason: format!("{e:?}"),
3248 });
3249 return;
3250 }
3251 };
3252
3253 // Unrecognized-ECC table: SST blocks (no `block_flags` byte) carry a
3254 // parity trailer whose length we can't derive without the descriptor
3255 // scheme, so this section can't be walked — stop here (the caller has
3256 // already warned). Self-describing blocks (`block_flags` present) size
3257 // parity from their `ECC_PARITY` bit, so those sections still walk.
3258 // Checked before the scanned-count increment so skipped blocks aren't
3259 // tallied. Sections are homogeneous in block type, so the first block
3260 // decides the whole section.
3261 if ctx.ecc_unrecognized && !Header::has_block_flags(header.block_type) {
3262 return;
3263 }
3264
3265 // Role cross-check: a checksum-clean block whose `block_type` was
3266 // re-stamped (a filter block relabeled as Data) passes every
3267 // byte-level check below, so the section-vs-role comparison is the
3268 // only out-of-band detector. Reported and then walked normally —
3269 // the header is internally valid, so the offsets stay trustworthy.
3270 if !ctx.expected_roles.contains(&header.block_type) {
3271 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3272 table_id: ctx.table_id,
3273 path: ctx.path.to_path_buf(),
3274 offset,
3275 reason: format!(
3276 "block role {:?} does not belong to this section (expected one of {:?})",
3277 header.block_type, ctx.expected_roles,
3278 ),
3279 });
3280 }
3281
3282 // Count the block as "header-read" immediately on successful
3283 // decode — matches the BlockVerifyReport.blocks_scanned docs
3284 // ("includes blocks where the data checksum subsequently
3285 // failed"). Without this early increment, blocks that emit
3286 // DataReadError / data-length-bounds HeaderCorrupted would
3287 // be silently uncounted, contradicting the documented
3288 // semantics.
3289 // Block counter; a tree cannot hold 2^64 blocks, so a plain add cannot
3290 // overflow.
3291 *ctx.blocks_scanned += 1;
3292
3293 // Actual header length for this block (variable: SST blocks omit the
3294 // block_flags byte). Used for the section-bounds math and the offset
3295 // advance so the walk tracks what `decode_from` actually consumed.
3296 let header_len = Header::header_len(header.block_type) as u64;
3297
3298 // Page-ECC parity trailer that follows the payload on disk. Presence
3299 // depends on the block type: Meta / Manifest / ManifestFooter keep the
3300 // block_flags byte and self-describe via the ECC_PARITY bit; SST blocks
3301 // omit the byte, so parity presence is the per-SST `page_ecc` flag. The
3302 // trailer length is derived from data_length (never stored). The walk
3303 // must skip these bytes — otherwise the next iteration would read parity
3304 // as the following block's header and mis-align the whole section.
3305 // Parity-trailer scheme to skip for this block. Self-describing blocks
3306 // (Meta / Manifest / `ManifestFooter`) carry the `block_flags` byte and
3307 // are written with the fixed RS(4,2) layout; SST blocks size their
3308 // trailer from the per-SST descriptor scheme threaded in via `ctx.ecc`.
3309 let block_ecc = if Header::has_block_flags(header.block_type) {
3310 (header.block_flags & crate::table::block::header::block_flags::ECC_PARITY != 0)
3311 .then_some(crate::table::block::EccParams::RS_4_2)
3312 } else {
3313 ctx.ecc
3314 };
3315 let parity_len = block_ecc.map_or(0, |scheme| {
3316 u64::from(crate::table::block::expected_parity_len(
3317 header.data_length,
3318 scheme,
3319 ))
3320 });
3321 // Hard cap on the parity trailer, mirroring the data_length cap
3322 // below: a syntactically valid but absurd shard layout (e.g.
3323 // RS(1,255), every payload byte amplified 255x into parity) drives
3324 // `expected_parity_len` toward its u32::MAX saturation point, and a
3325 // lying TOC length (forged, or a sparse file) would let the buffered
3326 // verify reserve that whole multi-GB trailer before any corruption
3327 // is reported. No real configuration produces a trailer above the
3328 // payload cap itself.
3329 if parity_len > MAX_BLOCK_DATA_LENGTH {
3330 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3331 table_id: ctx.table_id,
3332 path: ctx.path.to_path_buf(),
3333 offset,
3334 reason: format!(
3335 "parity trailer length {parity_len} exceeds hard cap {MAX_BLOCK_DATA_LENGTH}",
3336 ),
3337 });
3338 return;
3339 }
3340
3341 // Validate data_length against TWO bounds before allocating
3342 // / reading:
3343 //
3344 // 1. Hard cap (MAX_BLOCK_DATA_LENGTH = 256 MiB, mirroring
3345 // table::block::MAX_DECOMPRESSION_SIZE). Catches the case
3346 // where BOTH the block header AND the enclosing TOC entry
3347 // are simultaneously corrupted/forged so that `remaining`
3348 // becomes arbitrarily large. Without this, a forged TOC
3349 // entry with len=u64::MAX could let the section-bounds
3350 // check pass and trigger a multi-GB Vec::resize.
3351 //
3352 // 2. Remaining bytes in this TOC section. Header::decode_from
3353 // already verified the header's own XXH3, so a data_length
3354 // that overruns the section bounds is either bit-flip
3355 // corruption that happened to keep the header digest
3356 // valid (rare but possible), or fuzz input. Honouring it
3357 // would read past `end_offset` into the next section.
3358 //
3359 // Both bounds are reported as HeaderCorrupted — the header
3360 // was technically parseable but its length field is invalid.
3361 let data_length_u64 = u64::from(header.data_length);
3362 if data_length_u64 > ctx.max_data_length {
3363 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3364 table_id: ctx.table_id,
3365 path: ctx.path.to_path_buf(),
3366 offset,
3367 reason: format!(
3368 "header data_length {data_length_u64} exceeds hard cap {}",
3369 ctx.max_data_length,
3370 ),
3371 });
3372 return;
3373 }
3374 // A header whose own bytes cross the section boundary is corrupt and must
3375 // be rejected here: clamping `remaining` to zero would let a header with a
3376 // zero-length declared payload slip past the `>` check below even though
3377 // the header itself ran past the section end. Reuse the plain
3378 // `remaining_in_section` (the loop invariant `offset < end_offset` keeps
3379 // it non-negative) rather than recomputing it.
3380 if header_len > remaining_in_section {
3381 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3382 table_id: ctx.table_id,
3383 path: ctx.path.to_path_buf(),
3384 offset,
3385 reason: format!(
3386 "block header ({header_len} bytes) extends past the section end \
3387 ({remaining_in_section} bytes remain)",
3388 ),
3389 });
3390 return;
3391 }
3392 let remaining = remaining_in_section - header_len;
3393 // `data_length_u64` is already capped at `ctx.max_data_length` (checked
3394 // above) and `parity_len` is derived from it, so the sum is bounded well
3395 // within u64 — a plain add cannot overflow.
3396 let on_disk_payload = data_length_u64 + parity_len;
3397 if on_disk_payload > remaining {
3398 ctx.errors.push(BlockVerifyError::HeaderCorrupted {
3399 table_id: ctx.table_id,
3400 path: ctx.path.to_path_buf(),
3401 offset,
3402 reason: format!(
3403 "header data_length {data_length_u64} + parity {parity_len} exceeds \
3404 remaining section bytes {remaining}",
3405 ),
3406 });
3407 return;
3408 }
3409
3410 let data_length = header.data_length as usize;
3411 ctx.data_buf.resize(data_length, 0);
3412 // `as_mut_slice` returns the whole `Vec` (exactly `data_length`
3413 // bytes after the resize above) — full-slice access dodges
3414 // the crate-wide `#[deny(clippy::indexing_slicing)]`.
3415 if let Err(e) = ctx.reader.read_exact(ctx.data_buf.as_mut_slice()) {
3416 // Header was clean (XXH3 matched) but the data segment
3417 // that should follow it could not be read in full —
3418 // truncated SST, unexpected EOF, transient I/O.
3419 // Semantically distinct from HeaderCorrupted; reported
3420 // under its own variant so callers pattern-matching on
3421 // the error kind aren't surprised to find post-header
3422 // I/O failures bucketed with header-parse failures.
3423 ctx.errors.push(BlockVerifyError::DataReadError {
3424 table_id: ctx.table_id,
3425 path: ctx.path.to_path_buf(),
3426 offset,
3427 data_length: header.data_length,
3428 error: e.into(),
3429 });
3430 return;
3431 }
3432
3433 let computed = Checksum::from_raw(crate::hash::hash128(ctx.data_buf));
3434 // Verified at the offset the walk found it at: a block written at
3435 // another block's place is corrupt here, however clean its payload.
3436 let expected = header.payload_checksum(crate::table::block::ChecksumAt::block(
3437 ctx.table_id,
3438 header.block_type,
3439 offset,
3440 ));
3441 let payload_clean = computed == expected;
3442 if !payload_clean {
3443 ctx.errors.push(BlockVerifyError::DataCorrupted {
3444 table_id: ctx.table_id,
3445 path: ctx.path.to_path_buf(),
3446 offset,
3447 data_length: header.data_length,
3448 expected,
3449 got: computed,
3450 });
3451 }
3452
3453 // Consume the parity trailer (if any) so the reader cursor lands on
3454 // the next block's header — it MUST advance exactly `parity_len` bytes
3455 // or the next iteration mis-reads parity as a header. The trailer is
3456 // read into a buffer (not drained) so a build with the ECC codecs can
3457 // also VERIFY it: the payload checksum never covers the trailer, so
3458 // rot confined to parity reads as a clean block while its ECC is dead.
3459 if parity_len > 0 {
3460 let parity_usize = usize::try_from(parity_len).unwrap_or(usize::MAX);
3461 ctx.parity_buf.resize(parity_usize, 0);
3462 // A short read (EOF before `parity_len`) and an underlying read
3463 // error are the same outcome for the scrub: the trailer cannot be
3464 // consumed, so report a single DataReadError. (`read_exact`
3465 // retries `Interrupted` internally.)
3466 if let Err(e) = ctx.reader.read_exact(ctx.parity_buf.as_mut_slice()) {
3467 ctx.errors.push(BlockVerifyError::DataReadError {
3468 table_id: ctx.table_id,
3469 path: ctx.path.to_path_buf(),
3470 offset,
3471 data_length: header.data_length,
3472 error: e.into(),
3473 });
3474 return;
3475 }
3476 // Compare the stored trailer against parity freshly computed over
3477 // the payload — only when the payload itself is checksum-clean (a
3478 // corrupt payload legitimately mismatches its original trailer and
3479 // is already reported as DataCorrupted above). Only a build with
3480 // the ECC codecs can recompute parity; without `page_ecc` the
3481 // trailer is consumed for alignment but stays unverified (that
3482 // build cannot consume it on the read path either).
3483 #[cfg(feature = "page_ecc")]
3484 if payload_clean && let Some(scheme) = block_ecc {
3485 let fresh = match scheme {
3486 crate::table::block::EccParams::Secded => {
3487 Some(crate::secded::encode_block_parity(ctx.data_buf))
3488 }
3489 crate::table::block::EccParams::Shard { .. } => {
3490 let (ds, ps) = scheme.as_shards();
3491 crate::ecc::encode_parity(ctx.data_buf, ds, ps).ok()
3492 }
3493 };
3494 // An encoder that rejects a shape the writer accepted, or a
3495 // trailer that differs from the recomputed parity, both mean
3496 // the block's ECC cannot be trusted — fail loud either way.
3497 if fresh.as_deref() != Some(ctx.parity_buf.as_slice()) {
3498 ctx.errors.push(BlockVerifyError::EccParityMismatch {
3499 table_id: ctx.table_id,
3500 path: ctx.path.to_path_buf(),
3501 offset,
3502 data_length: header.data_length,
3503 });
3504 }
3505 }
3506 }
3507
3508 // blocks_scanned was already incremented right after a
3509 // successful Header::decode_from above — do not double-count
3510 // here.
3511 // Advance past this block. Each term is bounded (data_length capped
3512 // above, parity derived from it, header a const) and `offset` is bounded
3513 // by the section end, so the running cursor cannot overflow u64.
3514 offset += header_len + data_length_u64 + parity_len;
3515 }
3516}
3517
3518#[cfg(test)]
3519#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test assertions")]
3520mod block_verify_tests;