Skip to main content

plugmem_core/
snapshot.rs

1//! Snapshot container: the file format's header, section table and
2//! checksums.
3//!
4//! A snapshot is the engine's memory image: a 64-byte header, the binary
5//! [`Config`](crate::Config) block, a section table, then the sections —
6//! each one a structure dump from `plugmem-arena` (`dump_*`/`load`
7//! methods there), everything 64-byte aligned:
8//!
9//! ```text
10//! [header 64][config][pad][table: n × 32][pad][section][pad]...
11//! ```
12//!
13//! All container fields are little-endian. Byte layout of the header:
14//!
15//! | off | size | field |
16//! |---|---|---|
17//! | 0 | 4 | magic `0x504C_474D` ("PLGM") |
18//! | 4 | 2 | format version (this module: 1) |
19//! | 6 | 2 | flags (bit 0: vector section present; rest reserved zero) |
20//! | 8 | 2 | section count |
21//! | 10 | 6 | reserved, zero |
22//! | 16 | 4 | config block length |
23//! | 20 | 8 | file xxh3 |
24//! | 28 | 8 | created_at (informational, unix ms) |
25//! | 36 | 24 | engine version, UTF-8, zero-padded (informational) |
26//! | 60 | 4 | reserved, zero |
27//!
28//! and of one section-table entry:
29//!
30//! | off | size | field |
31//! |---|---|---|
32//! | 0 | 2 | kind |
33//! | 2 | 2 | alignment (always 64) |
34//! | 4 | 4 | reserved, zero |
35//! | 8 | 8 | offset from file start |
36//! | 16 | 8 | length |
37//! | 24 | 8 | section xxh3 |
38//!
39//! The file hash covers the **whole file with the hash field zeroed**
40//! (tightened relative to the original spec draft, which hashed only the
41//! bytes after the field and left the header prefix — notably `flags` —
42//! covered by nothing but field validation).
43//!
44//! # Trust model
45//!
46//! [`Snapshot::parse`] treats its input as untrusted: every offset and
47//! length is bounds-checked with u64 arithmetic before any access, and the
48//! layout must be *canonical* (sections contiguous in table order, all
49//! padding zero, no trailing bytes) — arbitrary bytes can produce any
50//! [`Error`] but never a panic. The container xxh3 checksums are **not**
51//! verified at parse; that runs on demand, in slices, via [`Snapshot::scrub`]
52//! (the ZFS-scrub model), keeping an open sparse on large files.
53
54use alloc::vec::Vec;
55
56use xxhash_rust::xxh3::{Xxh3, xxh3_64};
57
58use crate::error::Error;
59
60/// `"PLGM"` interpreted as a little-endian u32.
61pub const MAGIC: u32 = 0x504C_474D;
62
63/// Snapshot format version written and accepted by this build.
64pub const FORMAT_VERSION: u16 = 1;
65
66/// Flag bit: the image carries vector-layer sections.
67pub const FLAG_VECTORS: u16 = 1;
68
69/// Header size in bytes.
70const HEADER: usize = 64;
71
72/// Section-table entry size in bytes.
73const ENTRY: usize = 32;
74
75/// Section alignment (also the padding unit of every block).
76const ALIGN: usize = 64;
77
78/// Offset of the file-hash field inside the header.
79const FILE_HASH_AT: usize = 20;
80
81/// Rounds up to the next multiple of [`ALIGN`].
82fn align_up(v: u64) -> u64 {
83    v.div_ceil(ALIGN as u64) * ALIGN as u64
84}
85
86/// xxh3 of the whole file with the file-hash field zeroed.
87fn file_hash(bytes: &[u8]) -> u64 {
88    let mut h = Xxh3::new();
89    h.update(&bytes[..FILE_HASH_AT]);
90    h.update(&[0u8; 8]);
91    h.update(&bytes[FILE_HASH_AT + 8..]);
92    h.digest()
93}
94
95/// Builds a snapshot file from section dumps.
96///
97/// ```
98/// use plugmem_core::snapshot::{Snapshot, SnapshotWriter};
99///
100/// let mut w = SnapshotWriter::new();
101/// w.section(7, b"payload".to_vec()).unwrap();
102/// let bytes = w.finish(b"config-bytes", 0, 1_700_000_000_000, "0.1.0");
103/// let snap = Snapshot::parse(&bytes).unwrap();
104/// assert_eq!(snap.config(), b"config-bytes");
105/// assert_eq!(snap.section(7), Some(&b"payload"[..]));
106/// assert_eq!(snap.section(8), None);
107/// ```
108#[derive(Debug, Default)]
109pub struct SnapshotWriter {
110    sections: Vec<(u16, Vec<u8>)>,
111}
112
113impl SnapshotWriter {
114    /// An empty writer.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Adds one section. Kinds must be unique within a file.
120    ///
121    /// # Errors
122    ///
123    /// [`Error::Corrupt`] when `kind` was already added (a composition
124    /// bug surfaced as a typed error rather than a corrupt file).
125    pub fn section(&mut self, kind: u16, bytes: Vec<u8>) -> Result<(), Error> {
126        if self.sections.iter().any(|&(k, _)| k == kind) {
127            return Err(Error::Corrupt("duplicate section kind"));
128        }
129        self.sections.push((kind, bytes));
130        Ok(())
131    }
132
133    /// Assembles the final file: header, config block, table, aligned
134    /// sections, checksums. Deterministic — identical inputs produce
135    /// identical bytes.
136    ///
137    /// `engine_ver` is informational; its first 24 UTF-8 bytes are stored
138    /// (callers pass an ASCII semver in practice).
139    pub fn finish(self, config: &[u8], flags: u16, created_at: u64, engine_ver: &str) -> Vec<u8> {
140        let config_end = HEADER as u64 + config.len() as u64;
141        let table_start = align_up(config_end);
142        let table_end = table_start + (self.sections.len() * ENTRY) as u64;
143
144        // Lay out section offsets first.
145        let mut offsets = Vec::with_capacity(self.sections.len());
146        let mut cursor = align_up(table_end);
147        for (_, bytes) in &self.sections {
148            offsets.push(cursor);
149            cursor = align_up(cursor + bytes.len() as u64);
150        }
151        let file_len = cursor as usize;
152
153        let mut out = alloc::vec![0u8; file_len];
154        out[0..4].copy_from_slice(&MAGIC.to_le_bytes());
155        out[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
156        out[6..8].copy_from_slice(&flags.to_le_bytes());
157        out[8..10].copy_from_slice(&(self.sections.len() as u16).to_le_bytes());
158        out[16..20].copy_from_slice(&(config.len() as u32).to_le_bytes());
159        out[28..36].copy_from_slice(&created_at.to_le_bytes());
160        let ver = engine_ver.as_bytes();
161        let ver_len = ver.len().min(24);
162        out[36..36 + ver_len].copy_from_slice(&ver[..ver_len]);
163        out[HEADER..HEADER + config.len()].copy_from_slice(config);
164
165        for (i, (kind, bytes)) in self.sections.iter().enumerate() {
166            let at = table_start as usize + i * ENTRY;
167            out[at..at + 2].copy_from_slice(&kind.to_le_bytes());
168            out[at + 2..at + 4].copy_from_slice(&(ALIGN as u16).to_le_bytes());
169            out[at + 8..at + 16].copy_from_slice(&offsets[i].to_le_bytes());
170            out[at + 16..at + 24].copy_from_slice(&(bytes.len() as u64).to_le_bytes());
171            out[at + 24..at + 32].copy_from_slice(&xxh3_64(bytes).to_le_bytes());
172            let start = offsets[i] as usize;
173            out[start..start + bytes.len()].copy_from_slice(bytes);
174        }
175
176        let hash = file_hash(&out);
177        out[FILE_HASH_AT..FILE_HASH_AT + 8].copy_from_slice(&hash.to_le_bytes());
178        out
179    }
180}
181
182/// One section's size and checksum, computed in the streaming writer's
183/// first pass (see [`build_prefix`]).
184#[derive(Debug, Clone, Copy)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
186pub struct SectionMeta {
187    /// Section kind tag (unique per file).
188    pub kind: u16,
189    /// Section body length in bytes (before alignment padding).
190    pub len: u64,
191    /// xxh3 of the section body.
192    pub hash: u64,
193}
194
195/// The header + config + section-table region of a snapshot: everything
196/// before the first section body. Small and bounded (kilobytes), so it is
197/// materialized even when the bodies stream.
198#[derive(Debug)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct Prefix {
201    /// The prefix bytes, ready to write. The file-hash field is left zero;
202    /// the caller patches it via [`SnapshotSink::patch`] once the
203    /// whole body has streamed through the running hash.
204    pub bytes: Vec<u8>,
205    /// Absolute byte offset of each section body, in `metas` order.
206    pub offsets: Vec<u64>,
207    /// Total file length (aligned end of the last section).
208    pub file_len: u64,
209}
210
211/// A byte sink for streaming a snapshot without materializing the whole
212/// image: `write` appends in file order, `patch` overwrites a
213/// short run at an absolute offset — used once for the header file-hash
214/// field, the only non-sequential write, done after the body has streamed.
215pub trait SnapshotSink {
216    /// Appends `bytes` at the current position.
217    ///
218    /// # Errors
219    /// Whatever the underlying sink reports (e.g. an I/O error).
220    fn write(&mut self, bytes: &[u8]) -> Result<(), Error>;
221
222    /// Overwrites `bytes` at absolute offset `at` (already-written region).
223    ///
224    /// # Errors
225    /// Whatever the underlying sink reports.
226    fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error>;
227}
228
229impl SnapshotSink for &mut Vec<u8> {
230    fn write(&mut self, bytes: &[u8]) -> Result<(), Error> {
231        self.extend_from_slice(bytes);
232        Ok(())
233    }
234
235    fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
236        let at = at as usize;
237        self[at..at + bytes.len()].copy_from_slice(bytes);
238        Ok(())
239    }
240}
241
242/// Absolute offset of the header file-hash field — the one location the
243/// streaming writer patches after the running hash is known.
244pub const FILE_HASH_OFFSET: u64 = FILE_HASH_AT as u64;
245
246/// Lays out the header, config block and section table from per-section
247/// `(kind, len, hash)` metadata (the streaming writer's first pass), byte
248/// for byte identical to [`SnapshotWriter::finish`]'s leading region. The
249/// caller then streams each section body followed by
250/// [`pad_len`] zero bytes, feeding a running [`Xxh3`] the prefix and every
251/// body/pad byte, and finally calls [`SnapshotSink::patch`].
252pub fn build_prefix(
253    config: &[u8],
254    flags: u16,
255    created_at: u64,
256    engine_ver: &str,
257    metas: &[SectionMeta],
258) -> Prefix {
259    let count = metas.len();
260    let config_end = HEADER as u64 + config.len() as u64;
261    let table_start = align_up(config_end);
262    let table_end = table_start + (count * ENTRY) as u64;
263
264    let mut offsets = Vec::with_capacity(count);
265    let mut cursor = align_up(table_end);
266    for m in metas {
267        offsets.push(cursor);
268        cursor = align_up(cursor + m.len);
269    }
270    let file_len = cursor;
271
272    let prefix_len = align_up(table_end) as usize;
273    let mut out = alloc::vec![0u8; prefix_len];
274    out[0..4].copy_from_slice(&MAGIC.to_le_bytes());
275    out[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
276    out[6..8].copy_from_slice(&flags.to_le_bytes());
277    out[8..10].copy_from_slice(&(count as u16).to_le_bytes());
278    out[16..20].copy_from_slice(&(config.len() as u32).to_le_bytes());
279    out[28..36].copy_from_slice(&created_at.to_le_bytes());
280    let ver = engine_ver.as_bytes();
281    let ver_len = ver.len().min(24);
282    out[36..36 + ver_len].copy_from_slice(&ver[..ver_len]);
283    out[HEADER..HEADER + config.len()].copy_from_slice(config);
284
285    for (i, m) in metas.iter().enumerate() {
286        let at = table_start as usize + i * ENTRY;
287        out[at..at + 2].copy_from_slice(&m.kind.to_le_bytes());
288        out[at + 2..at + 4].copy_from_slice(&(ALIGN as u16).to_le_bytes());
289        out[at + 8..at + 16].copy_from_slice(&offsets[i].to_le_bytes());
290        out[at + 16..at + 24].copy_from_slice(&m.len.to_le_bytes());
291        out[at + 24..at + 32].copy_from_slice(&m.hash.to_le_bytes());
292    }
293    // The file-hash field (FILE_HASH_AT) stays zero; the caller patches it.
294    Prefix {
295        bytes: out,
296        offsets,
297        file_len,
298    }
299}
300
301/// Alignment-padding length that follows a section body of `len` starting
302/// at `offset` (zero bytes up to the next 64-byte boundary).
303pub fn pad_len(offset: u64, len: u64) -> usize {
304    (align_up(offset + len) - (offset + len)) as usize
305}
306
307/// A parsed, validated snapshot borrowing the input buffer.
308#[derive(Debug)]
309pub struct Snapshot<'a> {
310    bytes: &'a [u8],
311    /// Header flags (see [`FLAG_VECTORS`]).
312    pub flags: u16,
313    /// Creation timestamp as written (informational).
314    pub created_at: u64,
315    config_len: usize,
316    /// `(kind, start, len, stored_hash)` per section, in file order. The
317    /// stored xxh3 is retained (not verified at parse) so the on-demand
318    /// [`ScrubCursor`] can check it in slices.
319    sections: Vec<(u16, usize, usize, u64)>,
320    engine_ver_len: usize,
321}
322
323impl<'a> Snapshot<'a> {
324    /// Parses and structurally validates a snapshot file (trust model in the
325    /// module docs): framing, canonical layout and bounds only. The container
326    /// xxh3 checksums are **not** verified here — that is on demand, in slices,
327    /// via [`Snapshot::scrub`]. Content pools (text, vectors)
328    /// are validated lazily too; see [`Memory::verify`](crate::Memory::verify).
329    ///
330    /// # Errors
331    ///
332    /// [`Error::UnsupportedVersion`] for a foreign format version;
333    /// [`Error::Corrupt`] for everything else that fails structural validation.
334    pub fn parse(bytes: &'a [u8]) -> Result<Self, Error> {
335        if bytes.len() < HEADER {
336            return Err(Error::Corrupt("snapshot shorter than its header"));
337        }
338        if !bytes.len().is_multiple_of(ALIGN) {
339            return Err(Error::Corrupt("snapshot length is not 64-byte aligned"));
340        }
341        if u32::from_le_bytes(bytes[0..4].try_into().unwrap()) != MAGIC {
342            return Err(Error::Corrupt("bad magic"));
343        }
344        let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
345        if version != FORMAT_VERSION {
346            return Err(Error::UnsupportedVersion(version));
347        }
348        let flags = u16::from_le_bytes(bytes[6..8].try_into().unwrap());
349        if flags & !FLAG_VECTORS != 0 {
350            return Err(Error::Corrupt("unknown flag bits set"));
351        }
352        let section_cnt = u16::from_le_bytes(bytes[8..10].try_into().unwrap()) as usize;
353        if bytes[10..16] != [0u8; 6] || bytes[60..64] != [0u8; 4] {
354            return Err(Error::Corrupt("reserved header bytes must be zero"));
355        }
356        let config_len = u32::from_le_bytes(bytes[16..20].try_into().unwrap()) as usize;
357        let created_at = u64::from_le_bytes(bytes[28..36].try_into().unwrap());
358        let ver_bytes = &bytes[36..60];
359        let engine_ver_len = ver_bytes.iter().position(|&b| b == 0).unwrap_or(24);
360        if ver_bytes[engine_ver_len..].iter().any(|&b| b != 0) {
361            return Err(Error::Corrupt("engine version is not zero-terminated"));
362        }
363        if core::str::from_utf8(&ver_bytes[..engine_ver_len]).is_err() {
364            return Err(Error::Corrupt("engine version is not UTF-8"));
365        }
366
367        let file_len = bytes.len() as u64;
368        let config_end = HEADER as u64 + config_len as u64;
369        let table_start = align_up(config_end);
370        let table_end = table_start + (section_cnt * ENTRY) as u64;
371        if table_end > file_len {
372            return Err(Error::Corrupt("section table out of bounds"));
373        }
374        if bytes[config_end as usize..table_start as usize]
375            .iter()
376            .any(|&b| b != 0)
377        {
378            return Err(Error::Corrupt("nonzero padding after the config block"));
379        }
380
381        // The layout must be canonical: sections contiguous in table order.
382        let mut sections = Vec::with_capacity(section_cnt);
383        let mut expected = align_up(table_end);
384        if bytes[table_end as usize..expected as usize]
385            .iter()
386            .any(|&b| b != 0)
387        {
388            return Err(Error::Corrupt("nonzero padding after the section table"));
389        }
390        for i in 0..section_cnt {
391            let at = table_start as usize + i * ENTRY;
392            let kind = u16::from_le_bytes(bytes[at..at + 2].try_into().unwrap());
393            let align = u16::from_le_bytes(bytes[at + 2..at + 4].try_into().unwrap());
394            if align as usize != ALIGN {
395                return Err(Error::Corrupt("section alignment must be 64"));
396            }
397            if bytes[at + 4..at + 8] != [0u8; 4] {
398                return Err(Error::Corrupt("reserved section bytes must be zero"));
399            }
400            let offset = u64::from_le_bytes(bytes[at + 8..at + 16].try_into().unwrap());
401            let len = u64::from_le_bytes(bytes[at + 16..at + 24].try_into().unwrap());
402            let want = u64::from_le_bytes(bytes[at + 24..at + 32].try_into().unwrap());
403            if offset != expected {
404                return Err(Error::Corrupt("sections must be contiguous in table order"));
405            }
406            let end = offset
407                .checked_add(len)
408                .ok_or(Error::Corrupt("section length overflow"))?;
409            if end > file_len {
410                return Err(Error::Corrupt("section out of bounds"));
411            }
412            if sections.iter().any(|&(k, _, _, _)| k == kind) {
413                return Err(Error::Corrupt("duplicate section kind"));
414            }
415            expected = align_up(end);
416            if bytes[end as usize..expected.min(file_len) as usize]
417                .iter()
418                .any(|&b| b != 0)
419            {
420                return Err(Error::Corrupt("nonzero padding after a section"));
421            }
422            sections.push((kind, offset as usize, len as usize, want));
423        }
424        if expected != file_len {
425            return Err(Error::Corrupt("trailing bytes after the last section"));
426        }
427
428        Ok(Self {
429            bytes,
430            flags,
431            created_at,
432            config_len,
433            sections,
434            engine_ver_len,
435        })
436    }
437
438    /// The binary config block (decode with
439    /// [`Config::decode`](crate::Config::decode)).
440    pub fn config(&self) -> &'a [u8] {
441        &self.bytes[HEADER..HEADER + self.config_len]
442    }
443
444    /// The bytes of one section, or `None` when the file has no section of
445    /// that kind.
446    pub fn section(&self, kind: u16) -> Option<&'a [u8]> {
447        self.sections
448            .iter()
449            .find(|&&(k, _, _, _)| k == kind)
450            .map(|&(_, start, len, _)| &self.bytes[start..start + len])
451    }
452
453    /// The engine version string the file was written by (informational).
454    pub fn engine_ver(&self) -> &'a str {
455        core::str::from_utf8(&self.bytes[36..36 + self.engine_ver_len])
456            .expect("validated during parse")
457    }
458
459    /// A resumable container-checksum scan of this snapshot with the default
460    /// slice budget ([`DEFAULT_SCRUB_BUDGET`]). See [`ScrubCursor`].
461    pub fn scrub(&self) -> ScrubCursor<'a> {
462        self.scrub_with_budget(DEFAULT_SCRUB_BUDGET)
463    }
464
465    /// A resumable container-checksum scan hashing at most `budget` bytes per
466    /// [`Iterator::next`]. See [`ScrubCursor`].
467    pub fn scrub_with_budget(&self, budget: usize) -> ScrubCursor<'a> {
468        ScrubCursor {
469            bytes: self.bytes,
470            sections: self
471                .sections
472                .iter()
473                .map(|&(_, s, l, w)| (s, l, w))
474                .collect(),
475            budget: budget.max(1),
476            pos: 0,
477            sec: 0,
478            sec_hash: Xxh3::new(),
479            file: Xxh3::new(),
480            done: false,
481        }
482    }
483}
484
485/// Default number of bytes a [`ScrubCursor`] hashes per `next()` slice
486/// The `scrub` slice-size bench sweeps 64 KiB…64 MiB and finds
487/// throughput essentially flat (the scan is xxh3/memory-bandwidth-bound, not
488/// per-slice-overhead-bound), so the budget is a *pacing* quantum, not a
489/// throughput knob: 1 MiB is ~sub-millisecond per slice — fine-grained enough
490/// to pause, cancel or report progress smoothly, with negligible overhead.
491pub const DEFAULT_SCRUB_BUDGET: usize = 1 << 20;
492
493/// Progress of an in-flight [`ScrubCursor`] scan.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
496pub struct ScrubProgress {
497    /// Bytes checksummed so far (equals `total_bytes` on the final item).
498    pub done_bytes: u64,
499    /// The file length — the total to checksum.
500    pub total_bytes: u64,
501}
502
503/// A resumable, budget-bounded container-checksum scan over a parsed snapshot
504/// (— the ZFS-scrub model, run on demand instead of at open).
505///
506/// It implements [`Iterator`]: each `next()` hashes up to the slice budget,
507/// verifying each section's stored xxh3 as its body completes and the
508/// whole-file hash at EOF. It yields `Ok(ScrubProgress)` per slice, then
509/// `None`; the first mismatch yields `Err(Error::Corrupt)` and then `None`
510/// (fused). Because it only *reads* the bytes linearly, over an mmap the
511/// pages fault in, get hashed and stay reclaimable — a scrub never residents
512/// the whole file. It is one-shot: build a new cursor to scan again.
513pub struct ScrubCursor<'a> {
514    bytes: &'a [u8],
515    /// `(start, len, stored_hash)` per section, in file order.
516    sections: Vec<(usize, usize, u64)>,
517    budget: usize,
518    pos: usize,
519    /// Index of the section currently being hashed / next to complete.
520    sec: usize,
521    /// Streaming hash of the current section body.
522    sec_hash: Xxh3,
523    /// Streaming whole-file hash (with the file-hash field fed as zero).
524    file: Xxh3,
525    done: bool,
526}
527
528impl ScrubCursor<'_> {
529    /// Feeds `bytes[from..to]` into the whole-file hash, substituting zeros
530    /// for the 8-byte file-hash field — matching [`file_hash`] exactly.
531    fn feed_file(&mut self, from: usize, to: usize) {
532        let before_end = to.min(FILE_HASH_AT);
533        if from < before_end {
534            self.file.update(&self.bytes[from..before_end]);
535        }
536        let z_start = from.max(FILE_HASH_AT);
537        let z_end = to.min(FILE_HASH_AT + 8);
538        if z_start < z_end {
539            self.file.update(&[0u8; 8][..z_end - z_start]);
540        }
541        let after_start = from.max(FILE_HASH_AT + 8);
542        if after_start < to {
543            self.file.update(&self.bytes[after_start..to]);
544        }
545    }
546
547    /// Compares the current section's streamed digest to its stored hash and
548    /// advances to the next section (resetting the section hasher).
549    fn complete_section(&mut self, want: u64) -> Result<(), Error> {
550        if self.sec_hash.digest() != want {
551            self.done = true;
552            return Err(Error::Corrupt("section checksum mismatch"));
553        }
554        self.sec += 1;
555        self.sec_hash = Xxh3::new();
556        Ok(())
557    }
558}
559
560impl Iterator for ScrubCursor<'_> {
561    type Item = Result<ScrubProgress, Error>;
562
563    fn next(&mut self) -> Option<Self::Item> {
564        if self.done {
565            return None;
566        }
567        let n = self.sections.len();
568        let file_len = self.bytes.len();
569        let mut budget_left = self.budget;
570
571        while budget_left > 0 && self.pos < file_len {
572            // Flush any zero-length sections starting exactly here.
573            while self.sec < n {
574                let (start, len, want) = self.sections[self.sec];
575                if self.pos == start && len == 0 {
576                    if let Err(e) = self.complete_section(want) {
577                        return Some(Err(e));
578                    }
579                } else {
580                    break;
581                }
582            }
583
584            let (in_body, boundary) = match self.sections.get(self.sec) {
585                Some(&(start, len, _)) if self.pos >= start => (true, start + len),
586                Some(&(start, _, _)) => (false, start),
587                None => (false, file_len),
588            };
589            let end = (self.pos + budget_left).min(boundary);
590            self.feed_file(self.pos, end);
591            if in_body {
592                self.sec_hash.update(&self.bytes[self.pos..end]);
593            }
594            budget_left -= end - self.pos;
595            self.pos = end;
596
597            if in_body && self.pos == boundary {
598                let want = self.sections[self.sec].2;
599                if let Err(e) = self.complete_section(want) {
600                    return Some(Err(e));
601                }
602            }
603        }
604
605        if self.pos == file_len {
606            // Any trailing zero-length sections sit exactly at EOF.
607            while self.sec < n {
608                let want = self.sections[self.sec].2;
609                if let Err(e) = self.complete_section(want) {
610                    return Some(Err(e));
611                }
612            }
613            self.done = true;
614            let stored = u64::from_le_bytes(
615                self.bytes[FILE_HASH_AT..FILE_HASH_AT + 8]
616                    .try_into()
617                    .unwrap(),
618            );
619            // 0 means "no file hash" (the writer always emits one).
620            if stored != 0 && self.file.digest() != stored {
621                return Some(Err(Error::Corrupt("file checksum mismatch")));
622            }
623        }
624
625        Some(Ok(ScrubProgress {
626            done_bytes: self.pos as u64,
627            total_bytes: file_len as u64,
628        }))
629    }
630}