Skip to main content

heddle_object_model/object/manifest/
fsck.rs

1// SPDX-License-Identifier: Apache-2.0
2//! fsck rules for a manifest/object graph.
3//!
4//! Every rule has a name. A corrupt graph is not merely "invalid" — fsck says
5//! *which* invariant broke and at which node, because the operator response
6//! differs: a digest mismatch means the bytes are wrong, a dangling ref means
7//! publication tore, and an extent gap means a grant would authorize bytes
8//! nobody selected.
9//!
10//! The rules divide into four families:
11//!
12//! 1. **Well-formed** — every reachable node decodes canonically
13//!    ([`FsckRule::MalformedNode`], [`FsckRule::NonCanonicalNodeEncoding`],
14//!    [`FsckRule::LeafEntriesOutOfOrder`], …).
15//! 2. **Digests match** — node bytes hash to the address they were fetched by,
16//!    subtree summaries equal what the subtree actually holds, and every
17//!    encoded pack record hashes to its declared digest.
18//! 3. **No dangling refs** — every branch child resolves, every leaf object is
19//!    present in the object index, and no grant names an object the manifest
20//!    does not cover.
21//! 4. **Gap-free coverage** — a pack range's records partition `[start, end)`
22//!    exactly, in offset-canonical order, with no gap and no overlap.
23//!
24//! fsck is *checking*, not repair, and it is total: it collects every finding
25//! rather than bailing at the first, so one run tells an operator the whole
26//! story. Traversal is still bounded — visited nodes are not re-entered and
27//! depth cannot exceed the fixed route — so an adversarial node set cannot
28//! make it spin.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use super::{
33    build::{ManifestNodeSource, ManifestNodeStore, build_manifest},
34    extent::PackRangeClaim,
35    node::{
36        MANIFEST_LEAF_MAX_ENTRIES, MANIFEST_ROUTE_LEVELS, ManifestDecodeError, ManifestKey,
37        ManifestNode, ManifestObject,
38    },
39};
40use crate::object::ContentHash;
41
42// ── Rules ───────────────────────────────────────────────────────────
43
44/// The named integrity rules fsck enforces.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum FsckRule {
47    // Well-formed.
48    /// Node bytes failed to decode at all (bad magic, unknown version or tag,
49    /// unknown object kind, truncation).
50    MalformedNode,
51    /// Node bytes decoded but are not their own canonical spelling.
52    NonCanonicalNodeEncoding,
53    /// Node bytes carry data past the declared content.
54    TrailingBytes,
55    /// Leaf entries are not strictly ascending by `(kind, hash)`.
56    LeafEntriesOutOfOrder,
57    /// A leaf names one object key twice.
58    DuplicateObjectKey,
59    /// A branch declares an empty bitmap; the canonical empty set is a leaf.
60    EmptyBranchBitmap,
61
62    // Digests match.
63    /// Node bytes do not hash to the address they were fetched by.
64    NodeDigestMismatch,
65    /// A branch's `(object_count, decoded_bytes)` summary disagrees with its
66    /// actual subtree.
67    SubtreeSummaryMismatch,
68    /// A leaf's declared `decoded_size` disagrees with the object index.
69    ObjectSizeMismatch,
70    /// An encoded pack record does not hash to its declared digest.
71    ExtentDigestMismatch,
72
73    // No dangling refs.
74    /// A branch names a child that is absent from the node source.
75    DanglingNodeRef,
76    /// A leaf names an object that is absent from the object index.
77    DanglingObjectRef,
78    /// A pack claim authorizes bytes for an object the manifest does not cover.
79    ExtentObjectNotInManifest,
80    /// A node is present in the store but unreachable from the root.
81    UnreachableNode,
82
83    // Structural canonicity.
84    /// A leaf holds more than the bound while routing bits remain to split on.
85    LeafOverfull,
86    /// A branch's whole subtree would fit in one leaf; it must not exist.
87    UnderfullBranch,
88    /// A non-root leaf holds no entries.
89    EmptyNonRootLeaf,
90    /// A branch's declared depth disagrees with its position in the trie.
91    BranchDepthMismatch,
92    /// An entry sits at a position its route does not lead to.
93    MisroutedEntry,
94    /// Rebuilding the trie from the expanded object set yields a different
95    /// root. The backstop for any structural deviation the local rules miss.
96    NonCanonicalTrieShape,
97    /// Traversal exceeded the fixed route depth.
98    DepthExceeded,
99
100    // Gap-free coverage.
101    /// Pack records are not strictly ascending by offset.
102    ExtentsOutOfOffsetOrder,
103    /// Two pack records claim overlapping bytes.
104    ExtentOverlap,
105    /// Consecutive pack records leave an unclaimed byte gap.
106    ExtentGap,
107    /// The record partition does not cover `[start, end)` exactly.
108    RangeCoverageMismatch,
109    /// A pack record claims zero bytes.
110    ZeroLengthExtent,
111}
112
113impl FsckRule {
114    /// The stable rule name, for logs, metrics, and test assertions.
115    pub fn name(self) -> &'static str {
116        match self {
117            Self::MalformedNode => "malformed-node",
118            Self::NonCanonicalNodeEncoding => "non-canonical-node-encoding",
119            Self::TrailingBytes => "trailing-bytes",
120            Self::LeafEntriesOutOfOrder => "leaf-entries-out-of-order",
121            Self::DuplicateObjectKey => "duplicate-object-key",
122            Self::EmptyBranchBitmap => "empty-branch-bitmap",
123            Self::NodeDigestMismatch => "node-digest-mismatch",
124            Self::SubtreeSummaryMismatch => "subtree-summary-mismatch",
125            Self::ObjectSizeMismatch => "object-size-mismatch",
126            Self::ExtentDigestMismatch => "extent-digest-mismatch",
127            Self::DanglingNodeRef => "dangling-node-ref",
128            Self::DanglingObjectRef => "dangling-object-ref",
129            Self::ExtentObjectNotInManifest => "extent-object-not-in-manifest",
130            Self::UnreachableNode => "unreachable-node",
131            Self::LeafOverfull => "leaf-overfull",
132            Self::UnderfullBranch => "underfull-branch",
133            Self::EmptyNonRootLeaf => "empty-non-root-leaf",
134            Self::BranchDepthMismatch => "branch-depth-mismatch",
135            Self::MisroutedEntry => "misrouted-entry",
136            Self::NonCanonicalTrieShape => "non-canonical-trie-shape",
137            Self::DepthExceeded => "depth-exceeded",
138            Self::ExtentsOutOfOffsetOrder => "extents-out-of-offset-order",
139            Self::ExtentOverlap => "extent-overlap",
140            Self::ExtentGap => "extent-gap",
141            Self::RangeCoverageMismatch => "range-coverage-mismatch",
142            Self::ZeroLengthExtent => "zero-length-extent",
143        }
144    }
145}
146
147impl std::fmt::Display for FsckRule {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(self.name())
150    }
151}
152
153impl ManifestDecodeError {
154    /// Map a decode failure onto the fsck rule it violates, so a rejection is
155    /// reported by name rather than as an opaque parse error.
156    pub fn fsck_rule(&self) -> FsckRule {
157        match self {
158            Self::BadMagic
159            | Self::UnsupportedVersion(_)
160            | Self::UnknownNodeTag(_)
161            | Self::UnknownObjectKind(_)
162            | Self::Truncated => FsckRule::MalformedNode,
163            Self::TrailingBytes => FsckRule::TrailingBytes,
164            Self::EntriesOutOfOrder => FsckRule::LeafEntriesOutOfOrder,
165            Self::DuplicateObjectKey(_) => FsckRule::DuplicateObjectKey,
166            Self::EmptyBranchBitmap => FsckRule::EmptyBranchBitmap,
167            Self::NonCanonicalEncoding => FsckRule::NonCanonicalNodeEncoding,
168            Self::AddressMismatch { .. } => FsckRule::NodeDigestMismatch,
169        }
170    }
171}
172
173// ── Findings ────────────────────────────────────────────────────────
174
175/// One violation: the rule, where it was found, and a human-readable detail.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct FsckFinding {
178    pub rule: FsckRule,
179    /// The manifest node the finding attaches to, when there is one.
180    pub node: Option<ContentHash>,
181    pub detail: String,
182}
183
184impl FsckFinding {
185    fn new(rule: FsckRule, node: Option<ContentHash>, detail: impl Into<String>) -> Self {
186        Self {
187            rule,
188            node,
189            detail: detail.into(),
190        }
191    }
192}
193
194impl std::fmt::Display for FsckFinding {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self.node {
197            Some(node) => write!(f, "{}: {} ({})", self.rule, self.detail, node.short()),
198            None => write!(f, "{}: {}", self.rule, self.detail),
199        }
200    }
201}
202
203/// Every finding from one fsck run.
204#[derive(Clone, Debug, Default, PartialEq, Eq)]
205pub struct FsckReport {
206    findings: Vec<FsckFinding>,
207}
208
209impl FsckReport {
210    pub fn findings(&self) -> &[FsckFinding] {
211        &self.findings
212    }
213
214    /// True when nothing was found. A clean report is the *only* basis for
215    /// treating a root as usable.
216    pub fn is_clean(&self) -> bool {
217        self.findings.is_empty()
218    }
219
220    /// The distinct rules violated, in rule order.
221    pub fn violated_rules(&self) -> BTreeSet<FsckRule> {
222        self.findings.iter().map(|finding| finding.rule).collect()
223    }
224
225    pub fn has_rule(&self, rule: FsckRule) -> bool {
226        self.findings.iter().any(|finding| finding.rule == rule)
227    }
228
229    fn push(&mut self, finding: FsckFinding) {
230        self.findings.push(finding);
231    }
232}
233
234// ── Object index ────────────────────────────────────────────────────
235
236/// The set of content objects a manifest may legally name, with their decoded
237/// sizes.
238///
239/// Supplying an index turns on the dangling-object and size rules. Omitting it
240/// checks manifest structure alone, which is what a receiver that has the
241/// manifest but not yet the bodies can do.
242pub trait ManifestObjectIndex {
243    /// Decoded size of `key`, or `None` if the object is absent.
244    fn decoded_size(&self, key: &ManifestKey) -> Option<u64>;
245}
246
247impl ManifestObjectIndex for BTreeMap<ManifestKey, u64> {
248    fn decoded_size(&self, key: &ManifestKey) -> Option<u64> {
249        self.get(key).copied()
250    }
251}
252
253impl ManifestObjectIndex for std::collections::HashMap<ManifestKey, u64> {
254    fn decoded_size(&self, key: &ManifestKey) -> Option<u64> {
255        self.get(key).copied()
256    }
257}
258
259/// What fsck should check beyond node structure.
260#[derive(Default)]
261pub struct FsckOptions<'a> {
262    /// Objects the manifest may name. Enables [`FsckRule::DanglingObjectRef`]
263    /// and [`FsckRule::ObjectSizeMismatch`].
264    pub objects: Option<&'a dyn ManifestObjectIndex>,
265    /// Report nodes present in the store but unreachable from the root.
266    /// Off by default: a shared node store legitimately holds other roots'
267    /// nodes, so only a whole-store sweep should turn this on.
268    pub report_unreachable: bool,
269}
270
271// ── Manifest fsck ───────────────────────────────────────────────────
272
273/// Check a manifest graph rooted at `root` for structure and digests only.
274pub fn fsck_manifest<S: ManifestNodeSource + ?Sized>(source: &S, root: &ContentHash) -> FsckReport {
275    fsck_manifest_with(source, root, &FsckOptions::default())
276}
277
278/// Check a manifest graph rooted at `root`, with optional object and
279/// reachability checks.
280pub fn fsck_manifest_with<S: ManifestNodeSource + ?Sized>(
281    source: &S,
282    root: &ContentHash,
283    options: &FsckOptions<'_>,
284) -> FsckReport {
285    let mut report = FsckReport::default();
286    let mut visited = BTreeSet::new();
287    let mut objects = Vec::new();
288    let mut expansion_complete = true;
289
290    visit(
291        source,
292        root,
293        0,
294        true,
295        options,
296        &mut visited,
297        &mut objects,
298        &mut report,
299        &mut expansion_complete,
300    );
301
302    // Backstop: rebuild the canonical trie from what we actually expanded and
303    // compare roots. Local rules catch the diagnosable cases; this catches
304    // anything they do not. Skipped when expansion was incomplete, since a
305    // partial object set would rebuild to a different root for a reason
306    // already reported.
307    if expansion_complete
308        && let Ok(rebuilt) = build_manifest(objects.iter().copied())
309        && rebuilt.root != *root
310    {
311        report.push(FsckFinding::new(
312            FsckRule::NonCanonicalTrieShape,
313            Some(*root),
314            format!(
315                "rebuilding from {} expanded objects yields root {}",
316                objects.len(),
317                rebuilt.root
318            ),
319        ));
320    }
321
322    report
323}
324
325/// Check every root in `roots` against a whole node store, additionally
326/// reporting nodes no root reaches.
327pub fn fsck_manifest_store<S: ManifestNodeStore + ?Sized>(
328    store: &S,
329    roots: &[ContentHash],
330    options: &FsckOptions<'_>,
331) -> FsckReport {
332    let mut report = FsckReport::default();
333    let mut reachable = BTreeSet::new();
334
335    for root in roots {
336        let mut visited = BTreeSet::new();
337        let mut objects = Vec::new();
338        let mut expansion_complete = true;
339        visit(
340            store,
341            root,
342            0,
343            true,
344            options,
345            &mut visited,
346            &mut objects,
347            &mut report,
348            &mut expansion_complete,
349        );
350        if expansion_complete
351            && let Ok(rebuilt) = build_manifest(objects.iter().copied())
352            && rebuilt.root != *root
353        {
354            report.push(FsckFinding::new(
355                FsckRule::NonCanonicalTrieShape,
356                Some(*root),
357                format!("rebuilt root {} differs", rebuilt.root),
358            ));
359        }
360        reachable.extend(visited);
361    }
362
363    if options.report_unreachable {
364        for hash in store.node_hashes() {
365            if !reachable.contains(&hash) {
366                report.push(FsckFinding::new(
367                    FsckRule::UnreachableNode,
368                    Some(hash),
369                    "node is present but no supplied root reaches it",
370                ));
371            }
372        }
373    }
374
375    report
376}
377
378/// Visit one node, returning its `(object_count, decoded_bytes)` subtree
379/// summary when the subtree was readable enough to compute one.
380///
381/// `expansion_complete` is cleared only when a node could not be *read* —
382/// missing, mis-addressed, malformed, or past the route depth. A node that
383/// reads fine but violates a shape rule still expands completely, so the
384/// rebuild backstop stays meaningful for it.
385#[allow(clippy::too_many_arguments)]
386fn visit<S: ManifestNodeSource + ?Sized>(
387    source: &S,
388    hash: &ContentHash,
389    depth: u8,
390    is_root: bool,
391    options: &FsckOptions<'_>,
392    visited: &mut BTreeSet<ContentHash>,
393    objects: &mut Vec<ManifestObject>,
394    report: &mut FsckReport,
395    expansion_complete: &mut bool,
396) -> Option<(u64, u64)> {
397    if depth > MANIFEST_ROUTE_LEVELS {
398        *expansion_complete = false;
399        report.push(FsckFinding::new(
400            FsckRule::DepthExceeded,
401            Some(*hash),
402            format!("traversal passed the fixed {MANIFEST_ROUTE_LEVELS}-level route"),
403        ));
404        return None;
405    }
406    if !visited.insert(*hash) {
407        // Shared subtree, already accounted for. Content addressing makes a
408        // true cycle infeasible; this keeps a corrupt store from spinning.
409        return None;
410    }
411
412    let Some(bytes) = source.node_bytes(hash) else {
413        *expansion_complete = false;
414        report.push(FsckFinding::new(
415            FsckRule::DanglingNodeRef,
416            Some(*hash),
417            "node is referenced but absent from the node source",
418        ));
419        return None;
420    };
421
422    let actual = ContentHash::compute(bytes);
423    if actual != *hash {
424        *expansion_complete = false;
425        report.push(FsckFinding::new(
426            FsckRule::NodeDigestMismatch,
427            Some(*hash),
428            format!("bytes hash to {actual}"),
429        ));
430        return None;
431    }
432
433    let node = match ManifestNode::decode(bytes) {
434        Ok(node) => node,
435        Err(error) => {
436            *expansion_complete = false;
437            report.push(FsckFinding::new(
438                error.fsck_rule(),
439                Some(*hash),
440                error.to_string(),
441            ));
442            return None;
443        }
444    };
445
446    match node {
447        ManifestNode::Leaf(leaf) => {
448            let entries = leaf.entries();
449            if entries.is_empty() && !is_root {
450                report.push(FsckFinding::new(
451                    FsckRule::EmptyNonRootLeaf,
452                    Some(*hash),
453                    "only the root may be the empty leaf",
454                ));
455            }
456            if entries.len() > MANIFEST_LEAF_MAX_ENTRIES && depth < MANIFEST_ROUTE_LEVELS {
457                report.push(FsckFinding::new(
458                    FsckRule::LeafOverfull,
459                    Some(*hash),
460                    format!(
461                        "leaf at depth {depth} holds {} entries; the bound is {MANIFEST_LEAF_MAX_ENTRIES} while routing bits remain",
462                        entries.len()
463                    ),
464                ));
465            }
466
467            let mut decoded_bytes = 0u64;
468            for entry in entries {
469                decoded_bytes = decoded_bytes.saturating_add(entry.decoded_size);
470                if let Some(index) = options.objects {
471                    match index.decoded_size(&entry.key()) {
472                        None => {
473                            report.push(FsckFinding::new(
474                                FsckRule::DanglingObjectRef,
475                                Some(*hash),
476                                format!("{} object {} is not present", entry.kind, entry.hash),
477                            ));
478                        }
479                        Some(size) if size != entry.decoded_size => {
480                            report.push(FsckFinding::new(
481                                FsckRule::ObjectSizeMismatch,
482                                Some(*hash),
483                                format!(
484                                    "{} object {} declares {} bytes but holds {size}",
485                                    entry.kind, entry.hash, entry.decoded_size
486                                ),
487                            ));
488                        }
489                        Some(_) => {}
490                    }
491                }
492                objects.push(*entry);
493            }
494            Some((entries.len() as u64, decoded_bytes))
495        }
496        ManifestNode::Branch(branch) => {
497            if branch.depth() != depth {
498                report.push(FsckFinding::new(
499                    FsckRule::BranchDepthMismatch,
500                    Some(*hash),
501                    format!("declares depth {} but sits at {depth}", branch.depth()),
502                ));
503            }
504
505            let mut total_count = 0u64;
506            let mut total_bytes = 0u64;
507            let mut all_children_summarized = true;
508            for child in branch.children() {
509                let before = objects.len();
510                let summary = visit(
511                    source,
512                    &child.hash,
513                    depth + 1,
514                    false,
515                    options,
516                    visited,
517                    objects,
518                    report,
519                    expansion_complete,
520                );
521
522                // Everything newly expanded under this child must route through
523                // this branch's slot at this depth. Applied at every level of
524                // the descent, this checks the whole route, not just one group.
525                for entry in &objects[before..] {
526                    if entry.key().route().group(depth) != child.slot {
527                        report.push(FsckFinding::new(
528                            FsckRule::MisroutedEntry,
529                            Some(child.hash),
530                            format!(
531                                "{} object {} routes to slot {} at depth {depth}, not {}",
532                                entry.kind,
533                                entry.hash,
534                                entry.key().route().group(depth),
535                                child.slot
536                            ),
537                        ));
538                    }
539                }
540
541                match summary {
542                    Some((count, bytes)) => {
543                        if count != child.object_count || bytes != child.decoded_bytes {
544                            report.push(FsckFinding::new(
545                                FsckRule::SubtreeSummaryMismatch,
546                                Some(*hash),
547                                format!(
548                                    "slot {} summarizes ({}, {}) but holds ({count}, {bytes})",
549                                    child.slot, child.object_count, child.decoded_bytes
550                                ),
551                            ));
552                        }
553                        total_count += count;
554                        total_bytes = total_bytes.saturating_add(bytes);
555                    }
556                    None => {
557                        // Either a shared subtree (already counted) or a broken
558                        // one (already reported). Either way this branch's own
559                        // total is no longer checkable.
560                        all_children_summarized = false;
561                    }
562                }
563            }
564
565            if all_children_summarized {
566                if total_count <= MANIFEST_LEAF_MAX_ENTRIES as u64 && depth < MANIFEST_ROUTE_LEVELS
567                {
568                    report.push(FsckFinding::new(
569                        FsckRule::UnderfullBranch,
570                        Some(*hash),
571                        format!(
572                            "branch subtree holds {total_count} objects; it must be a single leaf"
573                        ),
574                    ));
575                }
576                Some((total_count, total_bytes))
577            } else {
578                None
579            }
580        }
581    }
582}
583
584// ── Pack-range fsck ─────────────────────────────────────────────────
585
586/// What to check a pack claim against, beyond its own structure.
587#[derive(Default)]
588pub struct PackRangeAudit<'a> {
589    /// The raw bytes of `[start, end)` as fetched, enabling
590    /// [`FsckRule::ExtentDigestMismatch`].
591    pub range_bytes: Option<&'a [u8]>,
592    /// Object keys the manifest authorizes, enabling
593    /// [`FsckRule::ExtentObjectNotInManifest`].
594    pub authorized: Option<&'a BTreeSet<ManifestKey>>,
595}
596
597/// Check that a coalesced pack range is offset-canonical, gap-free, and — when
598/// the caller supplies them — digest-correct and manifest-covered.
599///
600/// This is the rule that keeps one physical range read from authorizing an
601/// unselected byte gap in a mixed-audience pack.
602pub fn fsck_pack_range(claim: &PackRangeClaim, audit: &PackRangeAudit<'_>) -> FsckReport {
603    let mut report = FsckReport::default();
604
605    if claim.end < claim.start {
606        report.push(FsckFinding::new(
607            FsckRule::RangeCoverageMismatch,
608            None,
609            format!("range end {} precedes start {}", claim.end, claim.start),
610        ));
611        return report;
612    }
613
614    let records = claim.records();
615    if records.is_empty() {
616        if claim.end != claim.start {
617            report.push(FsckFinding::new(
618                FsckRule::RangeCoverageMismatch,
619                None,
620                format!(
621                    "range covers {} bytes but claims no records",
622                    claim.end - claim.start
623                ),
624            ));
625        }
626        return report;
627    }
628
629    let mut cursor = claim.start;
630    for (index, record) in records.iter().enumerate() {
631        if record.length == 0 {
632            report.push(FsckFinding::new(
633                FsckRule::ZeroLengthExtent,
634                None,
635                format!("record {index} ({}) claims zero bytes", record.object.hash),
636            ));
637        }
638
639        if index > 0 && record.offset < records[index - 1].offset {
640            report.push(FsckFinding::new(
641                FsckRule::ExtentsOutOfOffsetOrder,
642                None,
643                format!(
644                    "record {index} at offset {} follows offset {}",
645                    record.offset,
646                    records[index - 1].offset
647                ),
648            ));
649        }
650
651        if record.offset > cursor {
652            report.push(FsckFinding::new(
653                FsckRule::ExtentGap,
654                None,
655                format!(
656                    "unclaimed bytes [{cursor}, {}) before record {index}",
657                    record.offset
658                ),
659            ));
660        } else if record.offset < cursor {
661            report.push(FsckFinding::new(
662                FsckRule::ExtentOverlap,
663                None,
664                format!(
665                    "record {index} starts at {} inside claimed bytes ending at {cursor}",
666                    record.offset
667                ),
668            ));
669        }
670
671        let Some(end) = record.end() else {
672            report.push(FsckFinding::new(
673                FsckRule::RangeCoverageMismatch,
674                None,
675                format!("record {index} offset + length overflows u64"),
676            ));
677            return report;
678        };
679        cursor = cursor.max(end);
680
681        if let (Some(bytes), Some(record_end)) = (audit.range_bytes, record.end())
682            && record.offset >= claim.start
683        {
684            let from = (record.offset - claim.start) as usize;
685            let to = record_end.saturating_sub(claim.start) as usize;
686            match bytes.get(from..to) {
687                Some(slice) => {
688                    let digest = ContentHash::compute(slice);
689                    if digest != record.encoded_digest {
690                        report.push(FsckFinding::new(
691                            FsckRule::ExtentDigestMismatch,
692                            None,
693                            format!(
694                                "record {index} ({}) hashes to {digest}, not {}",
695                                record.object.hash, record.encoded_digest
696                            ),
697                        ));
698                    }
699                }
700                None => {
701                    report.push(FsckFinding::new(
702                        FsckRule::RangeCoverageMismatch,
703                        None,
704                        format!("record {index} extends past the supplied range bytes"),
705                    ));
706                }
707            }
708        }
709
710        if let Some(authorized) = audit.authorized
711            && !authorized.contains(&record.key())
712        {
713            report.push(FsckFinding::new(
714                FsckRule::ExtentObjectNotInManifest,
715                None,
716                format!(
717                    "record {index} authorizes {} object {}, which the manifest does not cover",
718                    record.object.kind, record.object.hash
719                ),
720            ));
721        }
722    }
723
724    if cursor != claim.end {
725        report.push(FsckFinding::new(
726            FsckRule::RangeCoverageMismatch,
727            None,
728            format!(
729                "records cover through {cursor} but the range ends at {}",
730                claim.end
731            ),
732        ));
733    }
734
735    if let Some(bytes) = audit.range_bytes
736        && let Some(expected) = claim.byte_len()
737        && bytes.len() as u64 != expected
738    {
739        report.push(FsckFinding::new(
740            FsckRule::RangeCoverageMismatch,
741            None,
742            format!("supplied {} bytes for a {expected}-byte range", bytes.len()),
743        ));
744    }
745
746    report
747}