Skip to main content

hyphae_storage/
snapshot.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{
4    fs::{File, OpenOptions},
5    io::{self, Read, Seek, SeekFrom, Write},
6    path::{Path, PathBuf},
7};
8
9use hyphae_core::DISK_FORMAT_VERSION;
10use thiserror::Error;
11
12use crate::{
13    CommitReceipt, MAX_KEY_BYTES, MaterializedIndexError, index::MaterializedIndex,
14    log::MAX_OPERATION_BYTES,
15};
16
17const MAGIC: [u8; 8] = *b"HYSNAP01";
18const HEADER_LENGTH: usize = 112;
19const HEADER_LENGTH_U64: u64 = 112;
20const CHECKSUM_PREFIX_LENGTH: usize = 76;
21const DIGEST_PREFIX_LENGTH: usize = 80;
22const ENTRY_HEADER_LENGTH: usize = 12;
23const ENTRY_HEADER_LENGTH_U64: u64 = 12;
24const RECEIPT_LENGTH: usize = 88;
25const RECEIPT_LENGTH_U64: u64 = 88;
26const COPY_BUFFER_LENGTH: usize = 64 * 1024;
27const COPY_BUFFER_LENGTH_U64: u64 = 64 * 1024;
28
29/// Verified metadata for one logical snapshot.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct SnapshotInfo {
32    /// Snapshot file path.
33    pub path: PathBuf,
34    /// Materialized commit sequence captured by the snapshot.
35    pub checkpoint_sequence: u64,
36    /// Commit digest captured by the snapshot, absent for an empty log.
37    pub checkpoint_digest: Option<[u8; 32]>,
38    /// Number of sorted KV entries.
39    pub entry_count: u64,
40    /// Number of sorted durable idempotency receipts.
41    pub receipt_count: u64,
42    /// BLAKE3 digest of the canonical snapshot content.
43    pub snapshot_digest: [u8; 32],
44    /// Complete file length.
45    pub file_bytes: u64,
46}
47
48/// Resource limits for loading a verified logical snapshot as an offline
49/// witness.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct SnapshotReadLimits {
52    /// Maximum complete snapshot file length.
53    pub file_bytes: u64,
54    /// Maximum number of logical KV entries.
55    pub entries: u64,
56    /// Maximum aggregate decoded key and value bytes retained in memory.
57    pub decoded_bytes: u64,
58}
59
60impl Default for SnapshotReadLimits {
61    fn default() -> Self {
62        Self {
63            file_bytes: 512 * 1024 * 1024,
64            entries: 1_000_000,
65            decoded_bytes: 256 * 1024 * 1024,
66        }
67    }
68}
69
70/// One verified logical KV entry loaded from a canonical snapshot.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct SnapshotEntry {
73    /// Nonempty binary key.
74    pub key: Vec<u8>,
75    /// Opaque stored value bytes.
76    pub value: Vec<u8>,
77}
78
79/// Fully verified logical snapshot contents for offline operations.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct SnapshotContents {
82    /// Verified snapshot metadata and checkpoint anchor.
83    pub info: SnapshotInfo,
84    /// Strictly key-ordered logical entries.
85    pub entries: Vec<SnapshotEntry>,
86}
87
88/// Failure while creating or verifying a logical snapshot.
89#[derive(Debug, Error)]
90pub enum SnapshotError {
91    /// A filesystem operation failed.
92    #[error(transparent)]
93    Io(#[from] io::Error),
94
95    /// The materialized index could not be read.
96    #[error("materialized index failure during snapshot: {source}")]
97    Index {
98        /// Underlying index failure.
99        #[source]
100        source: Box<MaterializedIndexError>,
101    },
102
103    /// Snapshot bytes violate the canonical format.
104    #[error("invalid snapshot: {reason}")]
105    Invalid {
106        /// Stable diagnostic reason.
107        reason: &'static str,
108    },
109
110    /// The snapshot was produced by a future disk format.
111    #[error("unsupported snapshot format {found}; supported format is {supported}")]
112    UnsupportedVersion {
113        /// Version found in the snapshot.
114        found: u16,
115        /// Version understood by this binary.
116        supported: u16,
117    },
118
119    /// A same-sequence snapshot exists for a different commit.
120    #[error("snapshot sequence {sequence} already exists for a different commit")]
121    CheckpointConflict {
122        /// Conflicting commit sequence.
123        sequence: u64,
124    },
125
126    /// Snapshot file length exceeds caller policy.
127    #[error("snapshot file length {actual} exceeds verification limit {maximum}")]
128    FileLimitExceeded {
129        /// Observed file length.
130        actual: u64,
131        /// Configured maximum.
132        maximum: u64,
133    },
134
135    /// Snapshot entry count exceeds caller policy.
136    #[error("snapshot entry count {actual} exceeds verification limit {maximum}")]
137    EntryLimitExceeded {
138        /// Observed entry count.
139        actual: u64,
140        /// Configured maximum.
141        maximum: u64,
142    },
143
144    /// Aggregate decoded entry bytes exceed caller policy.
145    #[error("snapshot decoded bytes exceed verification limit {maximum}")]
146    DecodedBytesLimitExceeded {
147        /// Configured maximum.
148        maximum: u64,
149    },
150}
151
152impl From<MaterializedIndexError> for SnapshotError {
153    fn from(source: MaterializedIndexError) -> Self {
154        Self::Index {
155            source: Box::new(source),
156        }
157    }
158}
159
160pub(crate) fn create_snapshot(
161    index: &MaterializedIndex,
162    snapshots_directory: &Path,
163    temporary_directory: &Path,
164) -> Result<SnapshotInfo, SnapshotError> {
165    let checkpoint = index.checkpoint()?;
166    if checkpoint.sequence == 0 && checkpoint.digest.is_some() {
167        return Err(SnapshotError::Invalid {
168            reason: "empty checkpoint has a digest",
169        });
170    }
171
172    let measurements = measure_payload(index, checkpoint.sequence)?;
173    let final_path =
174        snapshots_directory.join(format!("snapshot-{:020}.hysnap", checkpoint.sequence));
175    if final_path.exists() {
176        let existing = verify_snapshot(&final_path)?;
177        if existing.checkpoint_digest != checkpoint.digest {
178            return Err(SnapshotError::CheckpointConflict {
179                sequence: checkpoint.sequence,
180            });
181        }
182        return Ok(existing);
183    }
184
185    let mut header = [0_u8; HEADER_LENGTH];
186    header[0..8].copy_from_slice(&MAGIC);
187    header[8..10].copy_from_slice(&DISK_FORMAT_VERSION.to_le_bytes());
188    header[10..12].copy_from_slice(&0_u16.to_le_bytes());
189    header[12..20].copy_from_slice(&checkpoint.sequence.to_le_bytes());
190    header[20..52].copy_from_slice(&checkpoint.digest.unwrap_or([0; 32]));
191    header[52..60].copy_from_slice(&measurements.entry_count.to_le_bytes());
192    header[60..68].copy_from_slice(&measurements.receipt_count.to_le_bytes());
193    header[68..76].copy_from_slice(&measurements.payload_length.to_le_bytes());
194
195    let mut checksum = crc32c::crc32c(&header[..CHECKSUM_PREFIX_LENGTH]);
196    let mut checksum_error = None;
197    index.for_each_entry(|key, value| {
198        if checksum_error.is_some() {
199            return;
200        }
201        match encode_entry_header(key, value) {
202            Ok(entry_header) => {
203                checksum = crc32c::crc32c_append(checksum, &entry_header);
204                checksum = crc32c::crc32c_append(checksum, key);
205                checksum = crc32c::crc32c_append(checksum, value);
206            }
207            Err(source) => checksum_error = Some(source),
208        }
209    })?;
210    if let Some(source) = checksum_error {
211        return Err(source);
212    }
213    index.for_each_receipt(|receipt| {
214        checksum = crc32c::crc32c_append(checksum, &encode_receipt(receipt));
215    })?;
216    header[76..80].copy_from_slice(&checksum.to_le_bytes());
217
218    let temporary_path = temporary_directory.join(format!(
219        "snapshot-{:020}-{}.tmp",
220        checkpoint.sequence,
221        uuid::Uuid::now_v7()
222    ));
223    let mut file = OpenOptions::new()
224        .create_new(true)
225        .read(true)
226        .write(true)
227        .open(&temporary_path)?;
228    file.write_all(&header)?;
229    let mut hasher = blake3::Hasher::new();
230    hasher.update(&header[..DIGEST_PREFIX_LENGTH]);
231    let mut write_error = None;
232    index.for_each_entry(|key, value| {
233        if write_error.is_none()
234            && let Err(source) = write_entry(&mut file, &mut hasher, key, value)
235        {
236            write_error = Some(source);
237        }
238    })?;
239    if let Some(source) = write_error {
240        return Err(source);
241    }
242    let mut receipt_write_error = None;
243    index.for_each_receipt(|receipt| {
244        if receipt_write_error.is_none()
245            && let Err(source) = write_receipt(&mut file, &mut hasher, receipt)
246        {
247            receipt_write_error = Some(source);
248        }
249    })?;
250    if let Some(source) = receipt_write_error {
251        return Err(source);
252    }
253    let snapshot_digest = *hasher.finalize().as_bytes();
254    file.seek(SeekFrom::Start(80))?;
255    file.write_all(&snapshot_digest)?;
256    file.sync_all()?;
257    drop(file);
258
259    let temporary_info = verify_snapshot(&temporary_path)?;
260    std::fs::rename(&temporary_path, &final_path)?;
261    #[cfg(unix)]
262    sync_directory(snapshots_directory)?;
263    Ok(SnapshotInfo {
264        path: final_path,
265        ..temporary_info
266    })
267}
268
269/// Streams and verifies a snapshot without opening a Hyphae data directory.
270///
271/// # Errors
272///
273/// Returns an error for I/O, future versions, length mismatches, unsorted or
274/// duplicate keys, invalid checksums, or invalid digests.
275pub fn verify_snapshot(path: impl AsRef<Path>) -> Result<SnapshotInfo, SnapshotError> {
276    let path = path.as_ref();
277    let mut file = File::open(path)?;
278    let file_bytes = file.metadata()?.len();
279    let mut header = [0_u8; HEADER_LENGTH];
280    read_exact_or_invalid(&mut file, &mut header, "truncated header")?;
281    let decoded = decode_header(&header, file_bytes)?;
282    verify_payload(&mut file, &header, &decoded)?;
283
284    Ok(SnapshotInfo {
285        path: path.to_path_buf(),
286        checkpoint_sequence: decoded.checkpoint_sequence,
287        checkpoint_digest: decoded.checkpoint_digest,
288        entry_count: decoded.entry_count,
289        receipt_count: decoded.receipt_count,
290        snapshot_digest: decoded.expected_digest,
291        file_bytes,
292    })
293}
294
295/// Loads every logical KV entry from a verified snapshot under explicit
296/// resource limits.
297///
298/// The snapshot is verified before and after streaming to reject mutation
299/// during the read. Durable idempotency receipts are verified but are not
300/// retained in the returned witness.
301///
302/// # Errors
303///
304/// Returns a canonical snapshot error, I/O error, concurrent-change error, or
305/// resource-limit error.
306pub fn load_snapshot(
307    path: impl AsRef<Path>,
308    limits: &SnapshotReadLimits,
309) -> Result<SnapshotContents, SnapshotError> {
310    let path = path.as_ref();
311    let mut collector = SnapshotCollector {
312        entries: Vec::new(),
313        decoded_bytes: 0,
314        limits,
315    };
316    let info = read_snapshot_records_with_limits(path, &mut collector, Some(limits))?;
317    Ok(SnapshotContents {
318        info,
319        entries: collector.entries,
320    })
321}
322
323pub(crate) trait SnapshotRecordVisitor {
324    fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), SnapshotError>;
325    fn receipt(&mut self, receipt: &CommitReceipt) -> Result<(), SnapshotError>;
326}
327
328pub(crate) fn read_snapshot_records(
329    path: &Path,
330    visitor: &mut impl SnapshotRecordVisitor,
331) -> Result<SnapshotInfo, SnapshotError> {
332    read_snapshot_records_with_limits(path, visitor, None)
333}
334
335fn read_snapshot_records_with_limits(
336    path: &Path,
337    visitor: &mut impl SnapshotRecordVisitor,
338    limits: Option<&SnapshotReadLimits>,
339) -> Result<SnapshotInfo, SnapshotError> {
340    let before = verify_snapshot(path)?;
341    if let Some(limits) = limits {
342        validate_read_limits(&before, limits)?;
343    }
344    let mut file = File::open(path)?;
345    let file_bytes = file.metadata()?.len();
346    let mut header = [0_u8; HEADER_LENGTH];
347    read_exact_or_invalid(&mut file, &mut header, "truncated header")?;
348    let decoded = decode_header(&header, file_bytes)?;
349    let mut consumed = 0_u64;
350
351    for _ in 0..decoded.entry_count {
352        let mut entry_header = [0_u8; ENTRY_HEADER_LENGTH];
353        read_payload_exact(
354            &mut file,
355            &mut entry_header,
356            &mut consumed,
357            decoded.payload_length,
358        )?;
359        let key_length = usize::try_from(u32::from_le_bytes(copy_array(&entry_header[..4])))
360            .map_err(|_| SnapshotError::Invalid {
361                reason: "key length overflow during restore",
362            })?;
363        let value_length = usize::try_from(u64::from_le_bytes(copy_array(&entry_header[4..12])))
364            .map_err(|_| SnapshotError::Invalid {
365                reason: "value length overflow during restore",
366            })?;
367        if key_length == 0 || key_length > MAX_KEY_BYTES || value_length > MAX_OPERATION_BYTES {
368            return Err(SnapshotError::Invalid {
369                reason: "record exceeds restore bounds",
370            });
371        }
372        let mut key = vec![0_u8; key_length];
373        let mut value = vec![0_u8; value_length];
374        read_payload_exact(&mut file, &mut key, &mut consumed, decoded.payload_length)?;
375        read_payload_exact(&mut file, &mut value, &mut consumed, decoded.payload_length)?;
376        visitor.put(&key, &value)?;
377    }
378    for _ in 0..decoded.receipt_count {
379        let mut encoded = [0_u8; RECEIPT_LENGTH];
380        read_payload_exact(
381            &mut file,
382            &mut encoded,
383            &mut consumed,
384            decoded.payload_length,
385        )?;
386        visitor.receipt(&decode_snapshot_receipt(&encoded))?;
387    }
388    if consumed != decoded.payload_length {
389        return Err(SnapshotError::Invalid {
390            reason: "record counts do not consume payload during restore",
391        });
392    }
393
394    let after = verify_snapshot(path)?;
395    if before != after {
396        return Err(SnapshotError::Invalid {
397            reason: "snapshot changed during restore",
398        });
399    }
400    Ok(after)
401}
402
403fn validate_read_limits(
404    info: &SnapshotInfo,
405    limits: &SnapshotReadLimits,
406) -> Result<(), SnapshotError> {
407    if info.file_bytes > limits.file_bytes {
408        return Err(SnapshotError::FileLimitExceeded {
409            actual: info.file_bytes,
410            maximum: limits.file_bytes,
411        });
412    }
413    if info.entry_count > limits.entries {
414        return Err(SnapshotError::EntryLimitExceeded {
415            actual: info.entry_count,
416            maximum: limits.entries,
417        });
418    }
419    Ok(())
420}
421
422struct SnapshotCollector<'limits> {
423    entries: Vec<SnapshotEntry>,
424    decoded_bytes: u64,
425    limits: &'limits SnapshotReadLimits,
426}
427
428impl SnapshotRecordVisitor for SnapshotCollector<'_> {
429    fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), SnapshotError> {
430        let next_entry_count = u64::try_from(self.entries.len())
431            .ok()
432            .and_then(|count| count.checked_add(1))
433            .ok_or(SnapshotError::EntryLimitExceeded {
434                actual: u64::MAX,
435                maximum: self.limits.entries,
436            })?;
437        if next_entry_count > self.limits.entries {
438            return Err(SnapshotError::EntryLimitExceeded {
439                actual: next_entry_count,
440                maximum: self.limits.entries,
441            });
442        }
443        let entry_bytes = u64::try_from(key.len())
444            .ok()
445            .and_then(|key_bytes| {
446                u64::try_from(value.len())
447                    .ok()
448                    .and_then(|value_bytes| key_bytes.checked_add(value_bytes))
449            })
450            .ok_or(SnapshotError::DecodedBytesLimitExceeded {
451                maximum: self.limits.decoded_bytes,
452            })?;
453        self.decoded_bytes = self.decoded_bytes.checked_add(entry_bytes).ok_or(
454            SnapshotError::DecodedBytesLimitExceeded {
455                maximum: self.limits.decoded_bytes,
456            },
457        )?;
458        if self.decoded_bytes > self.limits.decoded_bytes {
459            return Err(SnapshotError::DecodedBytesLimitExceeded {
460                maximum: self.limits.decoded_bytes,
461            });
462        }
463        self.entries.push(SnapshotEntry {
464            key: key.to_vec(),
465            value: value.to_vec(),
466        });
467        Ok(())
468    }
469
470    fn receipt(&mut self, _receipt: &CommitReceipt) -> Result<(), SnapshotError> {
471        Ok(())
472    }
473}
474
475#[derive(Clone, Copy, Debug)]
476struct DecodedHeader {
477    checkpoint_sequence: u64,
478    checkpoint_digest: Option<[u8; 32]>,
479    entry_count: u64,
480    receipt_count: u64,
481    payload_length: u64,
482    expected_checksum: u32,
483    expected_digest: [u8; 32],
484}
485
486fn decode_header(
487    header: &[u8; HEADER_LENGTH],
488    file_bytes: u64,
489) -> Result<DecodedHeader, SnapshotError> {
490    if header[0..8] != MAGIC {
491        return Err(SnapshotError::Invalid {
492            reason: "bad magic",
493        });
494    }
495    let version = u16::from_le_bytes(copy_array(&header[8..10]));
496    if version != DISK_FORMAT_VERSION {
497        return Err(SnapshotError::UnsupportedVersion {
498            found: version,
499            supported: DISK_FORMAT_VERSION,
500        });
501    }
502    if u16::from_le_bytes(copy_array(&header[10..12])) != 0 {
503        return Err(SnapshotError::Invalid {
504            reason: "unsupported flags",
505        });
506    }
507
508    let checkpoint_sequence = u64::from_le_bytes(copy_array(&header[12..20]));
509    let raw_checkpoint_digest: [u8; 32] = copy_array(&header[20..52]);
510    let checkpoint_digest = if checkpoint_sequence == 0 {
511        if raw_checkpoint_digest != [0; 32] {
512            return Err(SnapshotError::Invalid {
513                reason: "empty checkpoint has a digest",
514            });
515        }
516        None
517    } else {
518        Some(raw_checkpoint_digest)
519    };
520    let entry_count = u64::from_le_bytes(copy_array(&header[52..60]));
521    let receipt_count = u64::from_le_bytes(copy_array(&header[60..68]));
522    if checkpoint_sequence == 0 && receipt_count != 0 {
523        return Err(SnapshotError::Invalid {
524            reason: "empty checkpoint has idempotency receipts",
525        });
526    }
527    let payload_length = u64::from_le_bytes(copy_array(&header[68..76]));
528    let expected_file_bytes =
529        HEADER_LENGTH_U64
530            .checked_add(payload_length)
531            .ok_or(SnapshotError::Invalid {
532                reason: "file length overflow",
533            })?;
534    if file_bytes != expected_file_bytes {
535        return Err(SnapshotError::Invalid {
536            reason: "file length mismatch",
537        });
538    }
539
540    Ok(DecodedHeader {
541        checkpoint_sequence,
542        checkpoint_digest,
543        entry_count,
544        receipt_count,
545        payload_length,
546        expected_checksum: u32::from_le_bytes(copy_array(&header[76..80])),
547        expected_digest: copy_array(&header[80..112]),
548    })
549}
550
551fn verify_payload(
552    file: &mut File,
553    header: &[u8; HEADER_LENGTH],
554    decoded: &DecodedHeader,
555) -> Result<(), SnapshotError> {
556    let mut checksum = crc32c::crc32c(&header[..CHECKSUM_PREFIX_LENGTH]);
557    let mut hasher = blake3::Hasher::new();
558    hasher.update(&header[..DIGEST_PREFIX_LENGTH]);
559    let mut consumed = 0_u64;
560    let mut previous_key: Option<Vec<u8>> = None;
561    let mut buffer = vec![0_u8; COPY_BUFFER_LENGTH].into_boxed_slice();
562    for _ in 0..decoded.entry_count {
563        let mut entry_header = [0_u8; ENTRY_HEADER_LENGTH];
564        read_payload_exact(
565            file,
566            &mut entry_header,
567            &mut consumed,
568            decoded.payload_length,
569        )?;
570        checksum = crc32c::crc32c_append(checksum, &entry_header);
571        hasher.update(&entry_header);
572        let key_length = usize::try_from(u32::from_le_bytes(copy_array(&entry_header[..4])))
573            .map_err(|_| SnapshotError::Invalid {
574                reason: "key length overflow",
575            })?;
576        let value_length = u64::from_le_bytes(copy_array(&entry_header[4..12]));
577        if key_length == 0 || key_length > MAX_KEY_BYTES {
578            return Err(SnapshotError::Invalid {
579                reason: "invalid key length",
580            });
581        }
582
583        let mut key = vec![0_u8; key_length];
584        read_payload_exact(file, &mut key, &mut consumed, decoded.payload_length)?;
585        checksum = crc32c::crc32c_append(checksum, &key);
586        hasher.update(&key);
587        if previous_key
588            .as_ref()
589            .is_some_and(|previous| previous >= &key)
590        {
591            return Err(SnapshotError::Invalid {
592                reason: "keys are not strictly sorted",
593            });
594        }
595        previous_key = Some(key);
596
597        let mut remaining = value_length;
598        while remaining > 0 {
599            let chunk_length =
600                usize::try_from(remaining.min(COPY_BUFFER_LENGTH_U64)).map_err(|_| {
601                    SnapshotError::Invalid {
602                        reason: "value length overflow",
603                    }
604                })?;
605            let chunk = &mut buffer[..chunk_length];
606            read_payload_exact(file, chunk, &mut consumed, decoded.payload_length)?;
607            checksum = crc32c::crc32c_append(checksum, chunk);
608            hasher.update(chunk);
609            remaining -= u64::try_from(chunk_length).map_err(|_| SnapshotError::Invalid {
610                reason: "value length overflow",
611            })?;
612        }
613    }
614    let mut previous_transaction_id = None;
615    for _ in 0..decoded.receipt_count {
616        let mut encoded = [0_u8; RECEIPT_LENGTH];
617        read_payload_exact(file, &mut encoded, &mut consumed, decoded.payload_length)?;
618        checksum = crc32c::crc32c_append(checksum, &encoded);
619        hasher.update(&encoded);
620
621        let transaction_id: [u8; 16] = copy_array(&encoded[..16]);
622        if previous_transaction_id
623            .as_ref()
624            .is_some_and(|previous| previous >= &transaction_id)
625        {
626            return Err(SnapshotError::Invalid {
627                reason: "transaction identifiers are not strictly sorted",
628            });
629        }
630        previous_transaction_id = Some(transaction_id);
631        let commit_sequence = u64::from_le_bytes(copy_array(&encoded[16..24]));
632        if commit_sequence == 0 || commit_sequence > decoded.checkpoint_sequence {
633            return Err(SnapshotError::Invalid {
634                reason: "idempotency receipt exceeds snapshot checkpoint",
635            });
636        }
637    }
638    if consumed != decoded.payload_length {
639        return Err(SnapshotError::Invalid {
640            reason: "record counts do not consume payload",
641        });
642    }
643    if checksum != decoded.expected_checksum {
644        return Err(SnapshotError::Invalid {
645            reason: "CRC32C mismatch",
646        });
647    }
648    let actual_digest = *hasher.finalize().as_bytes();
649    if actual_digest != decoded.expected_digest {
650        return Err(SnapshotError::Invalid {
651            reason: "BLAKE3 digest mismatch",
652        });
653    }
654    Ok(())
655}
656
657#[derive(Clone, Copy, Debug)]
658struct Measurements {
659    entry_count: u64,
660    receipt_count: u64,
661    payload_length: u64,
662}
663
664fn measure_payload(
665    index: &MaterializedIndex,
666    checkpoint_sequence: u64,
667) -> Result<Measurements, SnapshotError> {
668    let mut entry_count = Some(0_u64);
669    let mut payload_length = Some(0_u64);
670    let mut valid = true;
671    index.for_each_entry(|key, value| {
672        if key.is_empty() || key.len() > MAX_KEY_BYTES {
673            valid = false;
674            return;
675        }
676        let Ok(key_length) = u64::try_from(key.len()) else {
677            valid = false;
678            return;
679        };
680        let Ok(value_length) = u64::try_from(value.len()) else {
681            valid = false;
682            return;
683        };
684        entry_count = entry_count.and_then(|count| count.checked_add(1));
685        payload_length = payload_length.and_then(|length| {
686            length
687                .checked_add(ENTRY_HEADER_LENGTH_U64)
688                .and_then(|length| length.checked_add(key_length))
689                .and_then(|length| length.checked_add(value_length))
690        });
691    })?;
692    let mut receipt_count = Some(0_u64);
693    index.for_each_receipt(|receipt| {
694        if receipt.commit_sequence == 0 || receipt.commit_sequence > checkpoint_sequence {
695            valid = false;
696            return;
697        }
698        receipt_count = receipt_count.and_then(|count| count.checked_add(1));
699        payload_length = payload_length.and_then(|length| length.checked_add(RECEIPT_LENGTH_U64));
700    })?;
701    if !valid {
702        return Err(SnapshotError::Invalid {
703            reason: "index contains an invalid key or idempotency receipt",
704        });
705    }
706    let Some(entry_count) = entry_count else {
707        return Err(SnapshotError::Invalid {
708            reason: "entry count overflow",
709        });
710    };
711    let Some(payload_length) = payload_length else {
712        return Err(SnapshotError::Invalid {
713            reason: "payload length overflow",
714        });
715    };
716    let Some(receipt_count) = receipt_count else {
717        return Err(SnapshotError::Invalid {
718            reason: "receipt count overflow",
719        });
720    };
721    Ok(Measurements {
722        entry_count,
723        receipt_count,
724        payload_length,
725    })
726}
727
728fn encode_entry_header(
729    key: &[u8],
730    value: &[u8],
731) -> Result<[u8; ENTRY_HEADER_LENGTH], SnapshotError> {
732    let key_length = u32::try_from(key.len()).map_err(|_| SnapshotError::Invalid {
733        reason: "key length overflow",
734    })?;
735    let value_length = u64::try_from(value.len()).map_err(|_| SnapshotError::Invalid {
736        reason: "value length overflow",
737    })?;
738    let mut entry_header = [0_u8; ENTRY_HEADER_LENGTH];
739    entry_header[..4].copy_from_slice(&key_length.to_le_bytes());
740    entry_header[4..].copy_from_slice(&value_length.to_le_bytes());
741    Ok(entry_header)
742}
743
744fn write_entry(
745    writer: &mut impl Write,
746    hasher: &mut blake3::Hasher,
747    key: &[u8],
748    value: &[u8],
749) -> Result<(), SnapshotError> {
750    let entry_header = encode_entry_header(key, value)?;
751    for bytes in [&entry_header[..], key, value] {
752        writer.write_all(bytes)?;
753        hasher.update(bytes);
754    }
755    Ok(())
756}
757
758fn encode_receipt(receipt: &CommitReceipt) -> [u8; RECEIPT_LENGTH] {
759    let mut encoded = [0_u8; RECEIPT_LENGTH];
760    encoded[..16].copy_from_slice(receipt.transaction_id.as_bytes());
761    encoded[16..24].copy_from_slice(&receipt.commit_sequence.to_le_bytes());
762    encoded[24..56].copy_from_slice(&receipt.commit_digest);
763    encoded[56..88].copy_from_slice(&receipt.transaction_digest);
764    encoded
765}
766
767fn decode_snapshot_receipt(encoded: &[u8; RECEIPT_LENGTH]) -> CommitReceipt {
768    CommitReceipt {
769        transaction_id: uuid::Uuid::from_bytes(copy_array(&encoded[..16])),
770        commit_sequence: u64::from_le_bytes(copy_array(&encoded[16..24])),
771        commit_digest: copy_array(&encoded[24..56]),
772        transaction_digest: copy_array(&encoded[56..88]),
773    }
774}
775
776fn write_receipt(
777    writer: &mut impl Write,
778    hasher: &mut blake3::Hasher,
779    receipt: &CommitReceipt,
780) -> Result<(), SnapshotError> {
781    let encoded = encode_receipt(receipt);
782    writer.write_all(&encoded)?;
783    hasher.update(&encoded);
784    Ok(())
785}
786
787fn read_payload_exact(
788    reader: &mut impl Read,
789    buffer: &mut [u8],
790    consumed: &mut u64,
791    payload_length: u64,
792) -> Result<(), SnapshotError> {
793    let length = u64::try_from(buffer.len()).map_err(|_| SnapshotError::Invalid {
794        reason: "payload length overflow",
795    })?;
796    let next = consumed.checked_add(length).ok_or(SnapshotError::Invalid {
797        reason: "payload length overflow",
798    })?;
799    if next > payload_length {
800        return Err(SnapshotError::Invalid {
801            reason: "entry exceeds payload",
802        });
803    }
804    read_exact_or_invalid(reader, buffer, "truncated payload")?;
805    *consumed = next;
806    Ok(())
807}
808
809fn read_exact_or_invalid(
810    reader: &mut impl Read,
811    buffer: &mut [u8],
812    reason: &'static str,
813) -> Result<(), SnapshotError> {
814    reader.read_exact(buffer).map_err(|source| {
815        if source.kind() == io::ErrorKind::UnexpectedEof {
816            SnapshotError::Invalid { reason }
817        } else {
818            SnapshotError::Io(source)
819        }
820    })
821}
822
823#[cfg(unix)]
824fn sync_directory(path: &Path) -> Result<(), SnapshotError> {
825    File::open(path)?.sync_all()?;
826    Ok(())
827}
828
829fn copy_array<const N: usize>(source: &[u8]) -> [u8; N] {
830    let mut output = [0_u8; N];
831    output.copy_from_slice(source);
832    output
833}