Skip to main content

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