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