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